diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a443039..3f2d8d5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -19,6 +19,7 @@ import { SupportModule } from "./support/support.module.js"; import { HrTimeclockModule } from "./hr-timeclock/hr-timeclock.module.js"; import { FiscalModule } from "./fiscal/fiscal.module.js"; import { BillingModule } from "./billing/billing.module.js"; +import { DocumentsModule } from "./documents/documents.module.js"; @Module({ imports: [ @@ -41,6 +42,7 @@ import { BillingModule } from "./billing/billing.module.js"; HrTimeclockModule, FiscalModule, BillingModule, + DocumentsModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/documents/documents.controller.ts b/apps/api/src/documents/documents.controller.ts new file mode 100644 index 0000000..e7c3386 --- /dev/null +++ b/apps/api/src/documents/documents.controller.ts @@ -0,0 +1,77 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { DocumentsService } from "./documents.service"; +import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto"; +import { UpdateDraftDto } from "./dto/update-draft.dto"; +import { CreateGenerationDto } from "./dto/create-generation.dto"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; +import { PermissionGuard } from "../auth/permission.guard"; +import { RequirePermission } from "../auth/decorators/require-permission.decorator"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard"; + +/** + * Templates de documento + geração (eden.md §4, seções 1.1-1.3). Só a + * metade "templates + geração" do módulo 4 — Assinatura Eletrônica + * (seções 1.4-1.9) fica para uma leva dedicada futura (ver o comentário + * no topo da migration PENDING_documents.cjs). + */ +@ApiTags("documents") +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("v1/documents") +export class DocumentsController { + constructor(private readonly documents: DocumentsService) {} + + @Get("templates") + @RequirePermission("documents", "view") + listTemplates(@Query("docGroup") docGroup?: "cliente" | "revenda") { + return this.documents.listTemplates(docGroup); + } + + @Post("templates") + @RequirePermission("documents", "edit") + createTemplate(@Body() dto: CreateDocumentTemplateDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.documents.createTemplate(dto, user); + } + + @Get("templates/:id") + @RequirePermission("documents", "view") + getTemplate(@Param("id") id: string) { + return this.documents.getTemplate(id); + } + + @Post("templates/:id/draft") + @RequirePermission("documents", "edit") + createDraft(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) { + return this.documents.createDraft(id, user); + } + + @Patch("templates/:id/draft") + @RequirePermission("documents", "edit") + updateDraft(@Param("id") id: string, @Body() dto: UpdateDraftDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.documents.updateDraft(id, dto, user); + } + + @Post("templates/:id/publish") + @RequirePermission("documents", "edit") + publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) { + return this.documents.publish(id, user); + } + + @Post("generations") + @RequirePermission("documents", "edit") + createGeneration(@Body() dto: CreateGenerationDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.documents.createGeneration(dto, user); + } + + @Get("generations") + @RequirePermission("documents", "view") + listGenerations( + @Query("templateId") templateId?: string, + @Query("clientRegistrationId") clientRegistrationId?: string, + @Query("resellerId") resellerId?: string, + ) { + return this.documents.listGenerations({ templateId, clientRegistrationId, resellerId }); + } +} diff --git a/apps/api/src/documents/documents.module.ts b/apps/api/src/documents/documents.module.ts new file mode 100644 index 0000000..a05599f --- /dev/null +++ b/apps/api/src/documents/documents.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { DocumentsController } from "./documents.controller"; +import { DocumentsService } from "./documents.service"; + +@Module({ + imports: [AuthModule], + controllers: [DocumentsController], + providers: [DocumentsService], + exports: [DocumentsService], +}) +export class DocumentsModule {} diff --git a/apps/api/src/documents/documents.service.ts b/apps/api/src/documents/documents.service.ts new file mode 100644 index 0000000..0c76013 --- /dev/null +++ b/apps/api/src/documents/documents.service.ts @@ -0,0 +1,186 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { auditRepo, documentsRepo } from "@eden/database"; +import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard"; +import { mergeVariables } from "./merge-engine"; +import { renderEditorContentToHtml, resolveSourceText } from "./editor-content-renderer"; +import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto"; +import type { UpdateDraftDto } from "./dto/update-draft.dto"; +import type { CreateGenerationDto } from "./dto/create-generation.dto"; + +const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } = + documentsRepo; + +/** + * Templates de documento + geração — eden.md §4, seções 1.1-1.3. Só a + * metade "templates + geração" do módulo 4; Assinatura Eletrônica + * (seções 1.4-1.9) fica para uma leva dedicada futura (ver o comentário + * no topo da migration PENDING_documents.cjs). Endpoint-level + * authorization (@RequirePermission) lives on DocumentsController — this + * service assumes the caller already holds the resource+action it needs. + */ +@Injectable() +export class DocumentsService { + async listTemplates(docGroup?: "cliente" | "revenda") { + return documentsRepo.listTemplates(docGroup ? { docGroup } : {}); + } + + async getTemplate(id: string) { + const template = await documentsRepo.getTemplateWithVersions(id); + if (!template) throw new NotFoundException("Template de documento não encontrado"); + return template; + } + + async createTemplate(dto: CreateDocumentTemplateDto, user: EdenAuthenticatedUser) { + try { + const template = await documentsRepo.createTemplate({ + key: dto.key, + name: dto.name, + docGroup: dto.docGroup, + createdBy: user.id, + }); + + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "document_template.created", + resourceType: "document_template", + resourceId: template.id, + metadata: { key: template.key, docGroup: template.doc_group }, + }); + + return template; + } catch (err) { + if (err instanceof TemplateKeyConflictError) { + throw new ConflictException(`A chave '${dto.key}' já está em uso por outro template`); + } + throw err; + } + } + + async createDraft(templateId: string, user: EdenAuthenticatedUser) { + try { + const draft = await documentsRepo.createDraft(templateId, user.id, renderEditorContentToHtml); + + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "document_template.draft_created", + resourceType: "document_template_version", + resourceId: draft.id, + metadata: { templateId, version: draft.version }, + }); + + return draft; + } catch (err) { + if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado"); + if (err instanceof DraftAlreadyExistsError) { + throw new ConflictException("Este template já possui um rascunho em andamento"); + } + throw err; + } + } + + async updateDraft(templateId: string, dto: UpdateDraftDto, user: EdenAuthenticatedUser) { + try { + const updated = await documentsRepo.updateDraft( + templateId, + { editorContent: dto.editorContent, cssContent: dto.cssContent, pageConfig: dto.pageConfig }, + renderEditorContentToHtml, + ); + + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "document_template.draft_updated", + resourceType: "document_template_version", + resourceId: updated.id, + metadata: { templateId }, + }); + + return updated; + } catch (err) { + if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado"); + if (err instanceof DraftNotFoundError) { + throw new NotFoundException("Este template não possui um rascunho para editar"); + } + throw err; + } + } + + async publish(templateId: string, user: EdenAuthenticatedUser) { + try { + const published = await documentsRepo.publishDraft(templateId, user.id); + + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "document_template.published", + resourceType: "document_template_version", + resourceId: published.id, + metadata: { templateId, version: published.version }, + }); + + return published; + } catch (err) { + if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado"); + if (err instanceof DraftNotFoundError) { + throw new ConflictException("Este template não possui um rascunho para publicar"); + } + throw err; + } + } + + async createGeneration(dto: CreateGenerationDto, user: EdenAuthenticatedUser) { + const hasClient = dto.clientRegistrationId != null; + const hasReseller = dto.resellerId != null; + if (hasClient === hasReseller) { + throw new BadRequestException("Informe exatamente uma origem: clientRegistrationId OU resellerId"); + } + + let published: Awaited>; + try { + published = await documentsRepo.getPublishedVersionForTemplate(dto.templateId); + } catch (err) { + if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado"); + throw err; + } + if (!published) { + throw new NotFoundException("Este template ainda não tem uma versão publicada"); + } + + // Fluxo simplificado desta leva (eden.md §4 descreve o pipeline + // completo com renderToHtml/sanitizeDocumentHtml/Chromium/S3): roda o + // merge engine sobre o texto extraído de editor_content.plainText da + // versão publicada — sem renderização real de PDF (ver comentário na + // migration e no repositório documents.ts). + const sourceText = resolveSourceText(published.version.editor_content); + const { resolved, missingVariables } = mergeVariables(sourceText, dto.context ?? {}); + + const { generation, deduped } = await documentsRepo.createGenerationIdempotent({ + templateId: dto.templateId, + templateVersionId: published.version.id, + clientRegistrationId: dto.clientRegistrationId ?? null, + resellerId: dto.resellerId ?? null, + renderContext: dto.context ?? {}, + renderedHtml: resolved, + generatedBy: user.id, + }); + + if (!deduped) { + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "document_generation.created", + resourceType: "document_generation", + resourceId: generation.id, + metadata: { templateId: dto.templateId, templateVersionId: published.version.id, missingVariables }, + }); + } + + return { ...generation, missingVariables, deduped }; + } + + async listGenerations(filter: { templateId?: string; clientRegistrationId?: string; resellerId?: string }) { + return documentsRepo.listGenerations(filter); + } +} diff --git a/apps/api/src/documents/dto/create-document-template.dto.ts b/apps/api/src/documents/dto/create-document-template.dto.ts new file mode 100644 index 0000000..d758548 --- /dev/null +++ b/apps/api/src/documents/dto/create-document-template.dto.ts @@ -0,0 +1,18 @@ +import { IsIn, IsString, Matches, MaxLength } from "class-validator"; + +export class CreateDocumentTemplateDto { + /** Identificador estável fixado pelo sistema (eden.md §1.1) — só ASCII minúsculo/underscore, nunca editável depois de criado nesta leva. */ + @IsString() + @Matches(/^[a-z][a-z0-9_]*$/, { + message: "key deve começar com letra minúscula e conter apenas letras minúsculas, números e underscore", + }) + @MaxLength(100) + key!: string; + + @IsString() + @MaxLength(200) + name!: string; + + @IsIn(["cliente", "revenda"]) + docGroup!: "cliente" | "revenda"; +} diff --git a/apps/api/src/documents/dto/create-generation.dto.ts b/apps/api/src/documents/dto/create-generation.dto.ts new file mode 100644 index 0000000..d62f718 --- /dev/null +++ b/apps/api/src/documents/dto/create-generation.dto.ts @@ -0,0 +1,19 @@ +import { IsObject, IsOptional, IsUUID } from "class-validator"; + +/** + * POST /v1/documents/generations body (eden.md §1.3, simplified for this + * leva). Exactly one of clientRegistrationId/resellerId is required — + * enforced in DocumentsService (mirrors the DB CHECK + * chk_document_generations_one_source), not here, since class-validator + * doesn't cleanly express an XOR across two optional fields. + */ +export class CreateGenerationDto { + @IsUUID() templateId!: string; + + @IsOptional() @IsUUID() clientRegistrationId?: string; + + @IsOptional() @IsUUID() resellerId?: string; + + /** {chave: valor} usado pelo merge engine (merge-engine.ts) para resolver `{{variavel}}`. */ + @IsObject() context!: Record; +} diff --git a/apps/api/src/documents/dto/update-draft.dto.ts b/apps/api/src/documents/dto/update-draft.dto.ts new file mode 100644 index 0000000..7be2205 --- /dev/null +++ b/apps/api/src/documents/dto/update-draft.dto.ts @@ -0,0 +1,14 @@ +import { IsObject, IsOptional, IsString } from "class-validator"; + +/** + * PATCH .../draft body (eden.md §1.2 step 2) — every field optional and + * independently omit-able; an omitted field keeps the draft's current + * value (see documents.ts's updateDraft `patch.field !== undefined` + * semantics). `editorContent` is accepted as generic JSONB — this leva + * does not validate the Tiptap tree shape. + */ +export class UpdateDraftDto { + @IsOptional() @IsObject() editorContent?: Record; + @IsOptional() @IsString() cssContent?: string; + @IsOptional() @IsObject() pageConfig?: Record; +} diff --git a/apps/api/src/documents/editor-content-renderer.ts b/apps/api/src/documents/editor-content-renderer.ts new file mode 100644 index 0000000..6e88e67 --- /dev/null +++ b/apps/api/src/documents/editor-content-renderer.ts @@ -0,0 +1,62 @@ +/** + * Stand-in SIMPLIFICADO para o `renderToHtml()`/`renderEditorContent.js` + * do legado (eden.md §2.2) — aquele renderer conhece toda a árvore + * Tiptap (paragraph/heading/table/mergeField/conditional/...) e escapa + * HTML nó a nó. Esta leva de backend não tem o editor visual real ainda + * (`editor_content` é aceito como JSONB genérico, não validado), então + * em vez de reimplementar aquele renderer pela metade, este módulo faz + * só o mínimo necessário para `document_template_versions.html_content` + * nunca ficar dessincronizado do `editor_content` salvo: + * + * - se `editor_content` tiver uma propriedade de nível raiz + * `plainText` (string), ela é usada como o texto do documento; + * - caso contrário, um placeholder determinístico é usado. + * + * NÃO deve ser confundido com o merge-engine (merge-engine.ts), que roda + * DEPOIS, sobre o texto aqui extraído, para resolver `{{variavel}}`. + */ + +export const EDITOR_CONTENT_PLACEHOLDER_TEXT = + "[Conteúdo gerado pelo editor visual — renderização completa pendente de implementação]"; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Returns `editor_content.plainText` when present as a root-level string + * property, or `null` otherwise. Never throws on a malformed/unexpected + * shape — this leva doesn't validate the Tiptap tree at all (that's the + * visual editor's job once it exists). + */ +export function extractPlainText(editorContent: unknown): string | null { + if ( + editorContent !== null && + typeof editorContent === "object" && + "plainText" in (editorContent as Record) && + typeof (editorContent as Record).plainText === "string" + ) { + return (editorContent as { plainText: string }).plainText; + } + return null; +} + +/** + * `extractPlainText` with the deterministic placeholder as fallback — + * this is the actual text fed to the merge engine at generation time + * (documents.service.ts), never `null`/empty on that path. + */ +export function resolveSourceText(editorContent: unknown): string { + const plainText = extractPlainText(editorContent); + return plainText && plainText.length > 0 ? plainText : EDITOR_CONTENT_PLACEHOLDER_TEXT; +} + +/** Recomputes `document_template_versions.html_content` (eden.md §1.2 step 2). */ +export function renderEditorContentToHtml(editorContent: unknown): string { + return `

${escapeHtml(resolveSourceText(editorContent))}

`; +} diff --git a/apps/api/src/documents/merge-engine.ts b/apps/api/src/documents/merge-engine.ts new file mode 100644 index 0000000..1e77d52 --- /dev/null +++ b/apps/api/src/documents/merge-engine.ts @@ -0,0 +1,49 @@ +/** + * Motor de merge de variáveis — versão SIMPLIFICADA para esta leva, + * inspirada no `resolveDocument()` do legado (eden.md §3.2), mas + * operando sobre TEXTO PURO com placeholders `{{variavel}}` em vez da + * árvore Tiptap completa. A árvore Tiptap real (nós `mergeField`, + * `conditional`, `tableRow.attrs.repeat`, catálogo de variáveis + * `VARIABLE_CATALOG` com formatação por tipo — eden.md §2.2, §3.1, §3.3, + * §3.4) fica para quando o editor visual real existir; não é + * reimplementada aqui. + * + * Pura e sem I/O — testável isoladamente (ver + * apps/api/test/merge-engine.spec.ts). Diferença deliberada em relação + * ao motor completo do legado: aquele lança `MISSING_REQUIRED_FIELDS`/ + * `UNRESOLVED_VARIABLES` (HTTP 422) e bloqueia a geração; este NUNCA + * lança exceção — variáveis ausentes do contexto são substituídas por + * string vazia e apenas reportadas em `missingVariables` (deduplicadas, + * na ordem da primeira ocorrência). O chamador decide o que fazer com + * elas (bloquear, avisar, etc.); o motor não decide isso sozinho. + */ + +export interface MergeResult { + /** Texto com todo `{{variavel}}` substituído — nunca sobra placeholder pendente, mesmo quando ausente do contexto (vira string vazia). */ + resolved: string; + /** Chaves referenciadas no texto mas ausentes de `context`, deduplicadas, na ordem da primeira ocorrência. */ + missingVariables: string[]; +} + +// Mesma sintaxe de chave do catálogo legado (eden.md §3.1): namespace.campo, +// ex. "cliente.razao_social". Espaços em volta da chave são tolerados +// ("{{ cliente.razao_social }}") e ignorados. +const MERGE_FIELD_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g; + +export function mergeVariables(text: string, context: Record): MergeResult { + const missingVariables: string[] = []; + const seenMissing = new Set(); + + const resolved = text.replace(MERGE_FIELD_PATTERN, (_match: string, key: string) => { + if (Object.prototype.hasOwnProperty.call(context, key)) { + return context[key] ?? ""; + } + if (!seenMissing.has(key)) { + seenMissing.add(key); + missingVariables.push(key); + } + return ""; + }); + + return { resolved, missingVariables }; +} diff --git a/apps/reseller-web/.env.example b/apps/reseller-web/.env.example new file mode 100644 index 0000000..fa1a6d7 --- /dev/null +++ b/apps/reseller-web/.env.example @@ -0,0 +1,5 @@ +# Copiar para `.env` neste diretório (apps/reseller-web/.env) para +# desenvolvimento local com `pnpm --filter @eden/reseller-web dev` — Vite só +# lê .env do root do próprio app, não do root do monorepo (ver .env.example +# na raiz do repo). +VITE_API_BASE_URL=http://localhost:8080/api/v1 diff --git a/apps/reseller-web/package.json b/apps/reseller-web/package.json index 9f8acb2..bf4bb48 100644 --- a/apps/reseller-web/package.json +++ b/apps/reseller-web/package.json @@ -11,8 +11,10 @@ "test": "echo 'no tests yet'" }, "dependencies": { + "@eden/ui": "workspace:*", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1" }, "devDependencies": { "@types/react": "^18.3.18", diff --git a/apps/reseller-web/public/eden-logo-horizontal.png b/apps/reseller-web/public/eden-logo-horizontal.png new file mode 100644 index 0000000..43f6bdd Binary files /dev/null and b/apps/reseller-web/public/eden-logo-horizontal.png differ diff --git a/apps/reseller-web/src/App.tsx b/apps/reseller-web/src/App.tsx index f9612e4..4e315dd 100644 --- a/apps/reseller-web/src/App.tsx +++ b/apps/reseller-web/src/App.tsx @@ -1,12 +1,23 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./lib/auth/AuthContext.js"; +import { AuthenticatedLayout } from "./routes/AuthenticatedLayout.js"; +import { DashboardPage } from "./routes/DashboardPage.js"; +import { LoginPage } from "./routes/LoginPage.js"; +import { ProtectedRoute } from "./routes/ProtectedRoute.js"; + export function App() { return ( -
-
-

EDEN Parceiros

-

- Bootstrap da Fase 1 — Design System ainda não portado do tema DreamsERP. -

-
-
+ + + + } /> + }> + }> + } /> + + + + + ); } diff --git a/apps/reseller-web/src/lib/api/auth.ts b/apps/reseller-web/src/lib/api/auth.ts new file mode 100644 index 0000000..3d91c1a --- /dev/null +++ b/apps/reseller-web/src/lib/api/auth.ts @@ -0,0 +1,67 @@ +/** + * Client for `apps/api` `/v1/auth/*` (see apps/api/src/auth/auth.controller.ts + * and auth.service.ts for the source of truth — shapes below are mirrored, + * not invented, and were smoke-tested against the real running API). + */ +import { apiRequest } from "./http.js"; + +export interface PublicUser { + id: string; + email: string; + fullName: string | null; + roleKey: string; +} + +export interface AuthResult { + accessToken: string; + expiresIn: number; + refreshToken: string; + user: PublicUser; +} + +export interface MfaRequiredResult { + mfaRequired: true; + mfaChallenge: string; + expiresIn: number; +} + +export type LoginResult = AuthResult | MfaRequiredResult; + +export function isMfaRequired(result: LoginResult): result is MfaRequiredResult { + return "mfaRequired" in result && result.mfaRequired === true; +} + +export type PermissionGrant = { resource: string; action: string; scope: string }; + +export interface MeResult { + user: PublicUser; + permissions: "ALL" | PermissionGrant[]; + applications: Array<"core" | "reseller" | "subscriber">; +} + +const APPLICATION_KEY = "reseller" as const; + +export function login(email: string, password: string): Promise { + return apiRequest("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password, applicationKey: APPLICATION_KEY }), + }); +} + +export function completeMfaLogin(mfaChallenge: string, code: string): Promise { + return apiRequest("/auth/mfa/challenge", { + method: "POST", + body: JSON.stringify({ mfaChallenge, code }), + }); +} + +export function fetchMe(accessToken: string): Promise { + return apiRequest("/auth/me", { method: "GET" }, { accessToken }); +} + +export async function logout(refreshToken: string): Promise { + await apiRequest("/auth/logout", { + method: "POST", + body: JSON.stringify({ refreshToken }), + }); +} diff --git a/apps/reseller-web/src/lib/api/http.ts b/apps/reseller-web/src/lib/api/http.ts new file mode 100644 index 0000000..e5ab4ed --- /dev/null +++ b/apps/reseller-web/src/lib/api/http.ts @@ -0,0 +1,73 @@ +/** + * Minimal fetch wrapper for the EDEN API. Base URL comes from + * `VITE_API_BASE_URL` (set in apps/reseller-web/.env — see .env.example), + * falling back to the documented local dev default so `pnpm dev` works + * out of the box against `docker compose up eden-postgres` + a locally + * run `apps/api`. + */ +const DEFAULT_BASE_URL = "http://localhost:8080/api/v1"; + +export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? DEFAULT_BASE_URL; + +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +interface ApiErrorBody { + message?: string | string[]; + error?: string; + statusCode?: number; +} + +function extractErrorMessage(body: unknown, fallback: string): string { + const parsed = body as ApiErrorBody | null; + if (parsed && typeof parsed === "object") { + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + if (typeof parsed.error === "string") return parsed.error; + } + return fallback; +} + +export interface RequestOptions { + accessToken?: string; +} + +export async function apiRequest( + path: string, + init?: RequestInit, + options?: RequestOptions, +): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}), + ...init?.headers, + }, + }); + } catch { + throw new ApiError(0, "Não foi possível conectar à API. Verifique sua conexão e tente novamente."); + } + + if (response.status === 204) { + return undefined as TResponse; + } + + const text = await response.text(); + const body = text ? JSON.parse(text) : undefined; + + if (!response.ok) { + throw new ApiError(response.status, extractErrorMessage(body, "Ocorreu um erro inesperado.")); + } + + return body as TResponse; +} diff --git a/apps/reseller-web/src/lib/auth/AuthContext.tsx b/apps/reseller-web/src/lib/auth/AuthContext.tsx new file mode 100644 index 0000000..fc41fbe --- /dev/null +++ b/apps/reseller-web/src/lib/auth/AuthContext.tsx @@ -0,0 +1,109 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { + completeMfaLogin, + fetchMe, + isMfaRequired, + login as apiLogin, + logout as apiLogout, +} from "../api/auth.js"; +import type { PublicUser } from "../api/auth.js"; +import { clearSession, readSession, writeSession } from "./session.js"; + +export type AuthStatus = "loading" | "authenticated" | "anonymous"; + +/** Result of `login()` when the account has MFA enabled — caller must render a code field and call `submitMfa`. */ +export interface MfaPending { + mfaRequired: true; + mfaChallenge: string; +} + +interface AuthContextValue { + status: AuthStatus; + user: PublicUser | null; + accessToken: string | null; + /** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */ + login: (email: string, password: string) => Promise; + submitMfa: (mfaChallenge: string, code: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("loading"); + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); + + useEffect(() => { + const stored = readSession(); + if (!stored) { + setStatus("anonymous"); + return; + } + // Validate the persisted token against the API rather than trusting + // localStorage blindly — it may have expired since the last visit. + // No refresh-token rotation in this leva: an expired access token here + // just sends the user back to /login. + fetchMe(stored.accessToken) + .then((me) => { + setUser(me.user); + setAccessToken(stored.accessToken); + setRefreshToken(stored.refreshToken); + setStatus("authenticated"); + }) + .catch(() => { + clearSession(); + setStatus("anonymous"); + }); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const result = await apiLogin(email, password); + if (isMfaRequired(result)) { + return { mfaRequired: true as const, mfaChallenge: result.mfaChallenge }; + } + writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user }); + setUser(result.user); + setAccessToken(result.accessToken); + setRefreshToken(result.refreshToken); + setStatus("authenticated"); + return { mfaRequired: false as const }; + }, []); + + const submitMfa = useCallback(async (mfaChallenge: string, code: string) => { + const result = await completeMfaLogin(mfaChallenge, code); + writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user }); + setUser(result.user); + setAccessToken(result.accessToken); + setRefreshToken(result.refreshToken); + setStatus("authenticated"); + }, []); + + const logout = useCallback(async () => { + if (refreshToken) { + // Best-effort — even if the API call fails (e.g. session already + // expired server-side), the local session must still be cleared. + await apiLogout(refreshToken).catch(() => undefined); + } + clearSession(); + setUser(null); + setAccessToken(null); + setRefreshToken(null); + setStatus("anonymous"); + }, [refreshToken]); + + const value = useMemo( + () => ({ status, user, accessToken, login, submitMfa, logout }), + [status, user, accessToken, login, submitMfa, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within an AuthProvider"); + return ctx; +} diff --git a/apps/reseller-web/src/lib/auth/session.ts b/apps/reseller-web/src/lib/auth/session.ts new file mode 100644 index 0000000..2d80fc1 --- /dev/null +++ b/apps/reseller-web/src/lib/auth/session.ts @@ -0,0 +1,34 @@ +import type { PublicUser } from "../api/auth.js"; + +const STORAGE_KEY = "eden.reseller.session"; + +export interface StoredSession { + accessToken: string; + refreshToken: string; + user: PublicUser; +} + +/** + * Basic session persistence (localStorage) so a page refresh doesn't log + * the user out. Deliberately NOT rotating the refresh token automatically + * on expiry in this leva — see apps/reseller-web/src/lib/auth/AuthContext.tsx. + */ +export function readSession(): StoredSession | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as StoredSession; + if (!parsed.accessToken || !parsed.refreshToken || !parsed.user) return null; + return parsed; + } catch { + return null; + } +} + +export function writeSession(session: StoredSession): void { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); +} + +export function clearSession(): void { + window.localStorage.removeItem(STORAGE_KEY); +} diff --git a/apps/reseller-web/src/routes/AuthenticatedLayout.tsx b/apps/reseller-web/src/routes/AuthenticatedLayout.tsx new file mode 100644 index 0000000..65b2a9f --- /dev/null +++ b/apps/reseller-web/src/routes/AuthenticatedLayout.tsx @@ -0,0 +1,35 @@ +import { useNavigate } from "react-router-dom"; +import { Link, Outlet } from "react-router-dom"; +import { AppShell } from "@eden/ui"; +import type { AppShellNavItem } from "@eden/ui"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +const NAV_ITEMS: AppShellNavItem[] = [{ key: "dashboard", label: "Painel", href: "/", active: true }]; + +export function AuthenticatedLayout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout(); + navigate("/login", { replace: true }); + } + + return ( + ( + + {children} + + )} + > + + + ); +} diff --git a/apps/reseller-web/src/routes/DashboardPage.tsx b/apps/reseller-web/src/routes/DashboardPage.tsx new file mode 100644 index 0000000..1ac2bb4 --- /dev/null +++ b/apps/reseller-web/src/routes/DashboardPage.tsx @@ -0,0 +1,26 @@ +import { Card, PageHeader } from "@eden/ui"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +/** + * Placeholder landing page — out of scope for this leva to build real + * business screens (comissões, pedidos, etc.). This just proves the + * authenticated shell renders with real session data end to end. + */ +export function DashboardPage() { + const { user } = useAuth(); + + return ( +
+ + +

+ O shell autenticado está funcionando. As telas de negócio (comissões, pedidos, etc.) chegam nas próximas + levas. +

+
+
+ ); +} diff --git a/apps/reseller-web/src/routes/LoginPage.tsx b/apps/reseller-web/src/routes/LoginPage.tsx new file mode 100644 index 0000000..a1d787a --- /dev/null +++ b/apps/reseller-web/src/routes/LoginPage.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { Alert, Button, Card, Input } from "@eden/ui"; +import { ApiError } from "../lib/api/http.js"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +export function LoginPage() { + const { status, login, submitMfa } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [mfaChallenge, setMfaChallenge] = useState(null); + const [mfaCode, setMfaCode] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + if (status === "authenticated") { + const redirectTo = (location.state as { from?: string } | null)?.from ?? "/"; + return ; + } + + async function handleCredentialsSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + const result = await login(email, password); + if (result.mfaRequired) { + setMfaChallenge(result.mfaChallenge); + } else { + navigate("/", { replace: true }); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : "Não foi possível entrar. Tente novamente."); + } finally { + setSubmitting(false); + } + } + + async function handleMfaSubmit(event: FormEvent) { + event.preventDefault(); + if (!mfaChallenge) return; + setError(null); + setSubmitting(true); + try { + await submitMfa(mfaChallenge, mfaCode); + navigate("/", { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Código inválido. Tente novamente."); + } finally { + setSubmitting(false); + } + } + + return ( +
+ +
+ EDEN +

+ {mfaChallenge ? "Verificação em duas etapas" : "Bem-vindo de volta"} +

+

+ {mfaChallenge ? "Informe o código do seu aplicativo autenticador." : "Entre com sua conta EDEN Parceiros."} +

+
+ + {error ? ( + + {error} + + ) : null} + + {mfaChallenge ? ( +
+ setMfaCode(e.target.value)} + required + autoFocus + /> + + +
+ ) : ( +
+ setEmail(e.target.value)} + required + autoFocus + /> + setPassword(e.target.value)} + required + /> + +
+ )} +
+
+ ); +} diff --git a/apps/reseller-web/src/routes/ProtectedRoute.tsx b/apps/reseller-web/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..a7e083e --- /dev/null +++ b/apps/reseller-web/src/routes/ProtectedRoute.tsx @@ -0,0 +1,24 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +export function ProtectedRoute() { + const { status } = useAuth(); + const location = useLocation(); + + if (status === "loading") { + return ( +
+ +
+ ); + } + + if (status === "anonymous") { + return ; + } + + return ; +} diff --git a/apps/reseller-web/src/vite-env.d.ts b/apps/reseller-web/src/vite-env.d.ts new file mode 100644 index 0000000..d43868c --- /dev/null +++ b/apps/reseller-web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/reseller-web/tailwind.config.js b/apps/reseller-web/tailwind.config.js index 93aa364..0f62d78 100644 --- a/apps/reseller-web/tailwind.config.js +++ b/apps/reseller-web/tailwind.config.js @@ -1,6 +1,16 @@ +import edenPreset from "@eden/ui/tailwind-preset"; + /** @type {import('tailwindcss').Config} */ export default { - content: ["./index.html", "./src/**/*.{ts,tsx}"], + presets: [edenPreset], + content: [ + "./index.html", + "./src/**/*.{ts,tsx}", + // Design-system components live in packages/ui and ship as source (not + // pre-built CSS) — Tailwind's JIT scanner needs to see them too, or + // classes only used inside @eden/ui get purged from reseller-web's build. + "../../packages/ui/src/**/*.{ts,tsx}", + ], theme: { extend: {}, }, diff --git a/apps/subscriber-web/.env.example b/apps/subscriber-web/.env.example new file mode 100644 index 0000000..78509db --- /dev/null +++ b/apps/subscriber-web/.env.example @@ -0,0 +1,5 @@ +# Copiar para `.env` neste diretório (apps/subscriber-web/.env) para +# desenvolvimento local com `pnpm --filter @eden/subscriber-web dev` — Vite +# só lê .env do root do próprio app, não do root do monorepo (ver +# .env.example na raiz do repo). +VITE_API_BASE_URL=http://localhost:8080/api/v1 diff --git a/apps/subscriber-web/package.json b/apps/subscriber-web/package.json index 3d9fbc4..56b81f1 100644 --- a/apps/subscriber-web/package.json +++ b/apps/subscriber-web/package.json @@ -11,8 +11,10 @@ "test": "echo 'no tests yet'" }, "dependencies": { + "@eden/ui": "workspace:*", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1" }, "devDependencies": { "@types/react": "^18.3.18", diff --git a/apps/subscriber-web/public/eden-logo-horizontal.png b/apps/subscriber-web/public/eden-logo-horizontal.png new file mode 100644 index 0000000..43f6bdd Binary files /dev/null and b/apps/subscriber-web/public/eden-logo-horizontal.png differ diff --git a/apps/subscriber-web/src/App.tsx b/apps/subscriber-web/src/App.tsx index d4e1218..4e315dd 100644 --- a/apps/subscriber-web/src/App.tsx +++ b/apps/subscriber-web/src/App.tsx @@ -1,12 +1,23 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./lib/auth/AuthContext.js"; +import { AuthenticatedLayout } from "./routes/AuthenticatedLayout.js"; +import { DashboardPage } from "./routes/DashboardPage.js"; +import { LoginPage } from "./routes/LoginPage.js"; +import { ProtectedRoute } from "./routes/ProtectedRoute.js"; + export function App() { return ( -
-
-

EDEN Assinante

-

- Bootstrap da Fase 1 — Design System ainda não portado do tema DreamsERP. -

-
-
+ + + + } /> + }> + }> + } /> + + + + + ); } diff --git a/apps/subscriber-web/src/lib/api/auth.ts b/apps/subscriber-web/src/lib/api/auth.ts new file mode 100644 index 0000000..2193c53 --- /dev/null +++ b/apps/subscriber-web/src/lib/api/auth.ts @@ -0,0 +1,67 @@ +/** + * Client for `apps/api` `/v1/auth/*` (see apps/api/src/auth/auth.controller.ts + * and auth.service.ts for the source of truth — shapes below are mirrored, + * not invented, and were smoke-tested against the real running API). + */ +import { apiRequest } from "./http.js"; + +export interface PublicUser { + id: string; + email: string; + fullName: string | null; + roleKey: string; +} + +export interface AuthResult { + accessToken: string; + expiresIn: number; + refreshToken: string; + user: PublicUser; +} + +export interface MfaRequiredResult { + mfaRequired: true; + mfaChallenge: string; + expiresIn: number; +} + +export type LoginResult = AuthResult | MfaRequiredResult; + +export function isMfaRequired(result: LoginResult): result is MfaRequiredResult { + return "mfaRequired" in result && result.mfaRequired === true; +} + +export type PermissionGrant = { resource: string; action: string; scope: string }; + +export interface MeResult { + user: PublicUser; + permissions: "ALL" | PermissionGrant[]; + applications: Array<"core" | "reseller" | "subscriber">; +} + +const APPLICATION_KEY = "subscriber" as const; + +export function login(email: string, password: string): Promise { + return apiRequest("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password, applicationKey: APPLICATION_KEY }), + }); +} + +export function completeMfaLogin(mfaChallenge: string, code: string): Promise { + return apiRequest("/auth/mfa/challenge", { + method: "POST", + body: JSON.stringify({ mfaChallenge, code }), + }); +} + +export function fetchMe(accessToken: string): Promise { + return apiRequest("/auth/me", { method: "GET" }, { accessToken }); +} + +export async function logout(refreshToken: string): Promise { + await apiRequest("/auth/logout", { + method: "POST", + body: JSON.stringify({ refreshToken }), + }); +} diff --git a/apps/subscriber-web/src/lib/api/http.ts b/apps/subscriber-web/src/lib/api/http.ts new file mode 100644 index 0000000..c08775a --- /dev/null +++ b/apps/subscriber-web/src/lib/api/http.ts @@ -0,0 +1,73 @@ +/** + * Minimal fetch wrapper for the EDEN API. Base URL comes from + * `VITE_API_BASE_URL` (set in apps/subscriber-web/.env — see .env.example), + * falling back to the documented local dev default so `pnpm dev` works + * out of the box against `docker compose up eden-postgres` + a locally + * run `apps/api`. + */ +const DEFAULT_BASE_URL = "http://localhost:8080/api/v1"; + +export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? DEFAULT_BASE_URL; + +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +interface ApiErrorBody { + message?: string | string[]; + error?: string; + statusCode?: number; +} + +function extractErrorMessage(body: unknown, fallback: string): string { + const parsed = body as ApiErrorBody | null; + if (parsed && typeof parsed === "object") { + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + if (typeof parsed.error === "string") return parsed.error; + } + return fallback; +} + +export interface RequestOptions { + accessToken?: string; +} + +export async function apiRequest( + path: string, + init?: RequestInit, + options?: RequestOptions, +): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}), + ...init?.headers, + }, + }); + } catch { + throw new ApiError(0, "Não foi possível conectar à API. Verifique sua conexão e tente novamente."); + } + + if (response.status === 204) { + return undefined as TResponse; + } + + const text = await response.text(); + const body = text ? JSON.parse(text) : undefined; + + if (!response.ok) { + throw new ApiError(response.status, extractErrorMessage(body, "Ocorreu um erro inesperado.")); + } + + return body as TResponse; +} diff --git a/apps/subscriber-web/src/lib/auth/AuthContext.tsx b/apps/subscriber-web/src/lib/auth/AuthContext.tsx new file mode 100644 index 0000000..fc41fbe --- /dev/null +++ b/apps/subscriber-web/src/lib/auth/AuthContext.tsx @@ -0,0 +1,109 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { + completeMfaLogin, + fetchMe, + isMfaRequired, + login as apiLogin, + logout as apiLogout, +} from "../api/auth.js"; +import type { PublicUser } from "../api/auth.js"; +import { clearSession, readSession, writeSession } from "./session.js"; + +export type AuthStatus = "loading" | "authenticated" | "anonymous"; + +/** Result of `login()` when the account has MFA enabled — caller must render a code field and call `submitMfa`. */ +export interface MfaPending { + mfaRequired: true; + mfaChallenge: string; +} + +interface AuthContextValue { + status: AuthStatus; + user: PublicUser | null; + accessToken: string | null; + /** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */ + login: (email: string, password: string) => Promise; + submitMfa: (mfaChallenge: string, code: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("loading"); + const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); + + useEffect(() => { + const stored = readSession(); + if (!stored) { + setStatus("anonymous"); + return; + } + // Validate the persisted token against the API rather than trusting + // localStorage blindly — it may have expired since the last visit. + // No refresh-token rotation in this leva: an expired access token here + // just sends the user back to /login. + fetchMe(stored.accessToken) + .then((me) => { + setUser(me.user); + setAccessToken(stored.accessToken); + setRefreshToken(stored.refreshToken); + setStatus("authenticated"); + }) + .catch(() => { + clearSession(); + setStatus("anonymous"); + }); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const result = await apiLogin(email, password); + if (isMfaRequired(result)) { + return { mfaRequired: true as const, mfaChallenge: result.mfaChallenge }; + } + writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user }); + setUser(result.user); + setAccessToken(result.accessToken); + setRefreshToken(result.refreshToken); + setStatus("authenticated"); + return { mfaRequired: false as const }; + }, []); + + const submitMfa = useCallback(async (mfaChallenge: string, code: string) => { + const result = await completeMfaLogin(mfaChallenge, code); + writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user }); + setUser(result.user); + setAccessToken(result.accessToken); + setRefreshToken(result.refreshToken); + setStatus("authenticated"); + }, []); + + const logout = useCallback(async () => { + if (refreshToken) { + // Best-effort — even if the API call fails (e.g. session already + // expired server-side), the local session must still be cleared. + await apiLogout(refreshToken).catch(() => undefined); + } + clearSession(); + setUser(null); + setAccessToken(null); + setRefreshToken(null); + setStatus("anonymous"); + }, [refreshToken]); + + const value = useMemo( + () => ({ status, user, accessToken, login, submitMfa, logout }), + [status, user, accessToken, login, submitMfa, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within an AuthProvider"); + return ctx; +} diff --git a/apps/subscriber-web/src/lib/auth/session.ts b/apps/subscriber-web/src/lib/auth/session.ts new file mode 100644 index 0000000..57955ea --- /dev/null +++ b/apps/subscriber-web/src/lib/auth/session.ts @@ -0,0 +1,34 @@ +import type { PublicUser } from "../api/auth.js"; + +const STORAGE_KEY = "eden.subscriber.session"; + +export interface StoredSession { + accessToken: string; + refreshToken: string; + user: PublicUser; +} + +/** + * Basic session persistence (localStorage) so a page refresh doesn't log + * the user out. Deliberately NOT rotating the refresh token automatically + * on expiry in this leva — see apps/subscriber-web/src/lib/auth/AuthContext.tsx. + */ +export function readSession(): StoredSession | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as StoredSession; + if (!parsed.accessToken || !parsed.refreshToken || !parsed.user) return null; + return parsed; + } catch { + return null; + } +} + +export function writeSession(session: StoredSession): void { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); +} + +export function clearSession(): void { + window.localStorage.removeItem(STORAGE_KEY); +} diff --git a/apps/subscriber-web/src/routes/AuthenticatedLayout.tsx b/apps/subscriber-web/src/routes/AuthenticatedLayout.tsx new file mode 100644 index 0000000..26d3edc --- /dev/null +++ b/apps/subscriber-web/src/routes/AuthenticatedLayout.tsx @@ -0,0 +1,35 @@ +import { useNavigate } from "react-router-dom"; +import { Link, Outlet } from "react-router-dom"; +import { AppShell } from "@eden/ui"; +import type { AppShellNavItem } from "@eden/ui"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +const NAV_ITEMS: AppShellNavItem[] = [{ key: "dashboard", label: "Painel", href: "/", active: true }]; + +export function AuthenticatedLayout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout(); + navigate("/login", { replace: true }); + } + + return ( + ( + + {children} + + )} + > + + + ); +} diff --git a/apps/subscriber-web/src/routes/DashboardPage.tsx b/apps/subscriber-web/src/routes/DashboardPage.tsx new file mode 100644 index 0000000..fc6ff00 --- /dev/null +++ b/apps/subscriber-web/src/routes/DashboardPage.tsx @@ -0,0 +1,26 @@ +import { Card, PageHeader } from "@eden/ui"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +/** + * Placeholder landing page — out of scope for this leva to build real + * business screens (faturas, chamados, etc.). This just proves the + * authenticated shell renders with real session data end to end. + */ +export function DashboardPage() { + const { user } = useAuth(); + + return ( +
+ + +

+ O shell autenticado está funcionando. As telas de negócio (faturas, chamados, etc.) chegam nas próximas + levas. +

+
+
+ ); +} diff --git a/apps/subscriber-web/src/routes/LoginPage.tsx b/apps/subscriber-web/src/routes/LoginPage.tsx new file mode 100644 index 0000000..7951f6c --- /dev/null +++ b/apps/subscriber-web/src/routes/LoginPage.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { Alert, Button, Card, Input } from "@eden/ui"; +import { ApiError } from "../lib/api/http.js"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +export function LoginPage() { + const { status, login, submitMfa } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [mfaChallenge, setMfaChallenge] = useState(null); + const [mfaCode, setMfaCode] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + if (status === "authenticated") { + const redirectTo = (location.state as { from?: string } | null)?.from ?? "/"; + return ; + } + + async function handleCredentialsSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + const result = await login(email, password); + if (result.mfaRequired) { + setMfaChallenge(result.mfaChallenge); + } else { + navigate("/", { replace: true }); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : "Não foi possível entrar. Tente novamente."); + } finally { + setSubmitting(false); + } + } + + async function handleMfaSubmit(event: FormEvent) { + event.preventDefault(); + if (!mfaChallenge) return; + setError(null); + setSubmitting(true); + try { + await submitMfa(mfaChallenge, mfaCode); + navigate("/", { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Código inválido. Tente novamente."); + } finally { + setSubmitting(false); + } + } + + return ( +
+ +
+ EDEN +

+ {mfaChallenge ? "Verificação em duas etapas" : "Bem-vindo de volta"} +

+

+ {mfaChallenge ? "Informe o código do seu aplicativo autenticador." : "Entre com sua conta EDEN Assinante."} +

+
+ + {error ? ( + + {error} + + ) : null} + + {mfaChallenge ? ( +
+ setMfaCode(e.target.value)} + required + autoFocus + /> + + +
+ ) : ( +
+ setEmail(e.target.value)} + required + autoFocus + /> + setPassword(e.target.value)} + required + /> + +
+ )} +
+
+ ); +} diff --git a/apps/subscriber-web/src/routes/ProtectedRoute.tsx b/apps/subscriber-web/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..a7e083e --- /dev/null +++ b/apps/subscriber-web/src/routes/ProtectedRoute.tsx @@ -0,0 +1,24 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "../lib/auth/AuthContext.js"; + +export function ProtectedRoute() { + const { status } = useAuth(); + const location = useLocation(); + + if (status === "loading") { + return ( +
+ +
+ ); + } + + if (status === "anonymous") { + return ; + } + + return ; +} diff --git a/apps/subscriber-web/src/vite-env.d.ts b/apps/subscriber-web/src/vite-env.d.ts new file mode 100644 index 0000000..d43868c --- /dev/null +++ b/apps/subscriber-web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/subscriber-web/tailwind.config.js b/apps/subscriber-web/tailwind.config.js index 93aa364..abe4026 100644 --- a/apps/subscriber-web/tailwind.config.js +++ b/apps/subscriber-web/tailwind.config.js @@ -1,6 +1,16 @@ +import edenPreset from "@eden/ui/tailwind-preset"; + /** @type {import('tailwindcss').Config} */ export default { - content: ["./index.html", "./src/**/*.{ts,tsx}"], + presets: [edenPreset], + content: [ + "./index.html", + "./src/**/*.{ts,tsx}", + // Design-system components live in packages/ui and ship as source (not + // pre-built CSS) — Tailwind's JIT scanner needs to see them too, or + // classes only used inside @eden/ui get purged from subscriber-web's build. + "../../packages/ui/src/**/*.{ts,tsx}", + ], theme: { extend: {}, }, diff --git a/docs/progress.md b/docs/progress.md index 65f259a..128f567 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -268,3 +268,29 @@ Implementado: `infra/docker/Dockerfile.web` agora aceita `ARG VITE_API_BASE_URL` 3. **Não existia `.dockerignore`** no projeto — todo `COPY . .` do `Dockerfile.web`/`Dockerfile.node` estava copiando `node_modules/` (315MB), `.git/` (5.8MB) e `.claude/worktrees/` (345MB, os worktrees de subagentes desta mesma sessão) para dentro do contexto de build a cada vez. Além do desperdício óbvio, isso é parte de por que o bug nº1 passou despercebido no primeiro teste: caches `.turbo/` de dentro de cada pacote também eram copiados para dentro da imagem. Corrigido criando `.dockerignore` (exclui `node_modules`, `.git`, `dist`, `.turbo`, `.claude/worktrees`, `.tmp`, `.env*`, o zip do tema). Cada correção foi testada isoladamente e a última confirmada abrindo o JS de produção servido pelo container real (`docker compose build --no-cache`) e conferindo que o literal `"/api/v1"` (não mais `"http://localhost:8080/api/v1"`) está no bundle. + +--- + +## Fase 8 (parcial) — Portais Parceiros e Assinante replicam o shell autenticado do Core + +Data: 2026-09-03. Subagente `eden-frontend` replicou o mesmo padrão de login/sessão/shell do `core-web` (leva anterior) para `apps/reseller-web` e `apps/subscriber-web` — mesma estrutura de arquivos, mesmo cliente HTTP, mesmo `AuthContext`, só trocando `applicationKey` (`reseller`/`subscriber`), título, e a chave de `localStorage` da sessão (`eden.reseller.session`/`eden.subscriber.session`, isolamento explícito mesmo já sendo origens/portas diferentes). + +**Bug de integração (não do agente) encontrado e corrigido**: ao copiar `src/lib/` e `src/routes/` do worktree para `main`, o `cp -r` criou diretórios aninhados (`src/lib/lib/`, `src/routes/routes/`) porque os diretórios de destino já existiam vazios em `main` — mesmo padrão de erro já visto na integração do HR-Timeclock nesta sessão. Corrigido achatando a estrutura antes do typecheck. + +Verificado: `pnpm turbo run typecheck build` (28/28) após a correção; containers `eden-parceiros`/`eden-assinante` reconstruídos — títulos corretos (`EDEN Parceiros`/`EDEN Assinante`), texto antigo "Bootstrap da Fase 1" ausente do bundle de produção, formulário de login real presente; login de ponta a ponta testado contra a API real com `applicationKey: "reseller"` e `"subscriber"` — ambos aceitos e retornam tokens válidos. + +### Pendências +Idênticas às do `core-web` (rotação de refresh token, enroll de MFA na UI, telas de negócio) — nenhuma tela além do shell autenticado existe em nenhum dos 3 apps ainda. + +--- + +## Fase 3 (parcial) — Fundação de Templates de Documento (sem Assinatura Eletrônica ainda) + +Data: 2026-09-03. Subagente construiu a metade "templates + geração" do eden.md módulo 4 (§1.1-1.3) — a metade de Assinatura Eletrônica (§1.4-1.9: envelopes, signatários, OTP, cadeia de auditoria hash-chain, 9 tabelas satélite) foi deliberadamente deixada de fora, por ser criptograficamente/legalmente sensível e grande demais para dividir espaço com esta leva — fica para uma leva própria e dedicada. + +`document_templates` + `document_template_versions` (ciclo `draft→publish`, invariante de no máximo 1 rascunho por template via índice único parcial, versão publicada é imutável) + `document_generations` (idempotente por `(template_version_id, origem)`, CHECK garantindo exatamente uma origem). Dependência circular (`document_templates.current_version_id` ↔ `document_template_versions`) resolvida com `ALTER TABLE` depois que as duas tabelas existem. Motor de merge de variáveis (`{{chave}}` → valor do contexto, nunca lança exceção, reporta variáveis ausentes) implementado como função pura testável — o parser Tiptap real e a renderização de PDF via Playwright/Chromium ficam para quando o editor visual e essa infra existirem (simplificação documentada: `editor_content.plainText` como stand-in). + +Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api test` (30/30, sem regressão). Container `eden-api` reconstruído; testado ao vivo via curl: template criado → draft criado → segundo draft rejeitado (409, invariante confirmada) → draft editado com placeholders → publicado (`current_version_id` atualizado) → documento gerado com merge de variáveis funcionando (`{{empresa}}`/`{{cliente}}`/`{{valor}}` substituídos corretamente) → regeneração com os mesmos parâmetros reaproveita o registro existente (idempotência confirmada, mesmo `id`). Trilha de auditoria conferida no Postgres. + +### Pendências +Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. diff --git a/packages/database/migrations/1788468521075_documents.cjs b/packages/database/migrations/1788468521075_documents.cjs new file mode 100644 index 0000000..7ae10ab --- /dev/null +++ b/packages/database/migrations/1788468521075_documents.cjs @@ -0,0 +1,161 @@ +/** + * Document Templates + Generation — eden.md module 4 "Templates de + * Documentos, Geração de PDF e Assinatura Eletrônica", lines 2210-2825. + * + * This migration builds ONLY the "templates + generation" half of that + * module (eden.md sections 1.1, 1.2, 1.3): + * + * - document_templates (this migration) + * - document_template_versions (this migration) + * - document_generations (this migration) + * + * Deliberately DEFERRED to a future dedicated leva — the "Assinatura + * Eletrônica" half of eden.md module 4 (sections 1.4-1.9, 5, 6, 7): + * signature_envelopes, signature_signers, signature_documents, + * signature_otp_challenges, signature_audit_events (hash-chain), and the + * remaining satellite tables. That half is cryptographically/legally + * sensitive (hash-chain algorithm, HMAC-protected OTP codes, append-only + * triggers) and large (9 satellite tables) — it needs its own dedicated + * migration and review, not a rider on this one. + * + * Also NOT ported here (see eden.md §1.1): the legacy `body`/ + * `updated_by_id` columns on document_templates — client-side + * (html2canvas+jsPDF) text-with-{{placeholder}} storage from the OLD + * generation pipeline, superseded by editor_content (Tiptap JSON) for + * the new visual editor. Not seeded here either: the 7 real production + * templates (eden.md §1.1 table) — that's Handix production data to be + * migrated later, not schema. + * + * Circular-reference resolution (document_templates.current_version_id + * points at document_template_versions, which itself references + * document_templates.id): document_templates is created first WITHOUT a + * foreign key on current_version_id (column present, unconstrained), + * then document_template_versions is created referencing + * document_templates(id), and finally an ALTER TABLE below adds the + * deferred foreign key from document_templates.current_version_id to + * document_template_versions(id). No rows exist yet at that point, so + * there is nothing to backfill. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- document_templates - eden.md section 1.1. Never stores current + -- content directly; always points at a version via current_version_id. + -- ===================================================================== + CREATE TABLE document_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + doc_group TEXT NOT NULL CHECK (doc_group IN ('cliente', 'revenda')), + active BOOLEAN NOT NULL DEFAULT true, + current_version_id UUID, + created_by UUID REFERENCES users(id) + ); + COMMENT ON TABLE document_templates IS + 'Modelo de documento (eden.md secao 1.1). current_version_id aponta para a versao PUBLICADA vigente em document_template_versions; NULL enquanto o template nunca foi publicado. A FK de current_version_id e adicionada mais abaixo, depois que document_template_versions existe (dependencia circular resolvida com ALTER TABLE). key e um identificador estavel fixado pelo sistema (ex.: client_software_contract), nunca editavel pelo usuario. Os campos legados body/updated_by_id do OrcaFacil (texto puro com placeholders, geracao client-side) nao foram portados aqui: pertencem ao pipeline antigo, ja substituido pelo editor visual Tiptap (editor_content em document_template_versions).'; + COMMENT ON COLUMN document_templates.doc_group IS + 'Agrupa a listagem na UI: cliente (contratos/termos voltados ao cliente final) ou revenda (contratos/termos de canal).'; + + CREATE TRIGGER trg_document_templates_updated_at BEFORE UPDATE ON document_templates + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + CREATE INDEX idx_document_templates_doc_group ON document_templates(doc_group); + + -- ===================================================================== + -- document_template_versions - eden.md section 1.2. Versionamento + -- IMUTAVEL assim que status='published'. + -- ===================================================================== + CREATE TABLE document_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID NOT NULL REFERENCES document_templates(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('draft', 'published')), + editor_content JSONB NOT NULL, + html_content TEXT DEFAULT '', + css_content TEXT, + page_config JSONB DEFAULT '{}', + created_by UUID REFERENCES users(id), + published_at TIMESTAMPTZ, + published_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (template_id, version) + ); + COMMENT ON TABLE document_template_versions IS + 'Versionamento de conteudo de um document_template (eden.md secao 1.2) - imutavel assim que status=published (nenhuma rota edita uma versao publicada). editor_content e aceito aqui como JSONB generico vindo do cliente da API - esta leva de backend NAO valida a arvore Tiptap (responsabilidade do editor visual, que ainda nao existe). html_content e recalculado a cada PATCH .../draft por uma funcao propria SIMPLIFICADA (apps/api/src/documents/editor-content-renderer.ts) - NAO e o renderToHtml completo do Tiptap do legado (eden.md secao 2.2): se editor_content tiver uma propriedade de nivel raiz plainText (string), ela e usada como texto; senao um placeholder deterministico e serializado. E um stand-in ate o editor visual real existir.'; + COMMENT ON COLUMN document_template_versions.status IS + 'draft ou published. Invariante central (eden.md secao 1.2): no maximo 1 draft por template - reforcada pelo indice unico parcial uq_document_template_versions_one_draft abaixo. Uma versao published e imutavel; publicar de novo exige um novo draft (POST .../draft), nunca reescreve uma ja publicada.'; + COMMENT ON COLUMN document_template_versions.version IS + 'Sequencial por template (UNIQUE(template_id, version)), atribuido pela camada de repositorio como MAX(version)+1 no momento da criacao do draft - nunca reaproveita numero, mesmo que versoes antigas nunca sejam apagadas.'; + + -- Invariante central do eden.md secao 1.2: no maximo 1 rascunho por + -- template, a qualquer momento. + CREATE UNIQUE INDEX uq_document_template_versions_one_draft + ON document_template_versions(template_id) WHERE status = 'draft'; + + CREATE INDEX idx_document_template_versions_template ON document_template_versions(template_id); + + -- Dependencia circular resolvida: agora que document_template_versions + -- existe, adiciona a FK de current_version_id. + ALTER TABLE document_templates + ADD CONSTRAINT fk_document_templates_current_version + FOREIGN KEY (current_version_id) REFERENCES document_template_versions(id); + + -- ===================================================================== + -- document_generations - eden.md section 1.3. Snapshot de cada PDF + -- definitivo emitido - nunca recalculado depois. + -- ===================================================================== + CREATE TABLE document_generations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + template_id UUID NOT NULL REFERENCES document_templates(id), + template_version_id UUID NOT NULL REFERENCES document_template_versions(id), + client_registration_id UUID, + reseller_id UUID, + render_context JSONB DEFAULT '{}', + rendered_html TEXT, + pdf_s3_key TEXT, + pdf_sha256 TEXT, + pdf_size_bytes INTEGER, + status TEXT NOT NULL DEFAULT 'generating' CHECK (status IN ('generating', 'final', 'error')), + error_message TEXT, + generated_by UUID REFERENCES users(id), + CONSTRAINT chk_document_generations_one_source CHECK ( + (client_registration_id IS NOT NULL)::int + (reseller_id IS NOT NULL)::int = 1 + ) + ); + COMMENT ON TABLE document_generations IS + 'Snapshot imutavel de cada geracao de documento (eden.md secao 1.3). client_registration_id/reseller_id SEM FK: client_registrations/resellers podem nao existir neste worktree isolado - mesmo padrao de FK cruzada adiada usado por Quotes/Contracts/Support neste monorepo. Nesta leva NAO ha renderizacao real de PDF (Playwright/Chromium fica para uma leva futura - eden.md secao 4.1): pdf_s3_key/pdf_sha256/pdf_size_bytes permanecem sempre NULL aqui; a linha e gravada com status=final e rendered_html preenchido assim que o merge de variaveis (apps/api/src/documents/merge-engine.ts) roda sobre o texto extraido de editor_content.plainText da versao publicada.'; + COMMENT ON CONSTRAINT chk_document_generations_one_source ON document_generations IS + 'Exatamente uma origem preenchida (client_registration_id XOR reseller_id) - eden.md secao 1.3. O caso especial do Termo de Cessao (cedente/cessionaria, cessionaria sem coluna propria) nao se aplica a esta leva (sem seed/uso de templates reais ainda).'; + + CREATE INDEX idx_document_generations_template_version ON document_generations(template_version_id); + CREATE INDEX idx_document_generations_client ON document_generations(client_registration_id); + CREATE INDEX idx_document_generations_reseller ON document_generations(reseller_id); + + -- Idempotencia simplificada (eden.md secao 1.3 descreve uma regra mais + -- rica com force/extra_fields - fora do escopo desta leva): no maximo + -- uma geracao status=final por (template_version_id, origem). Reforca + -- em nivel de banco a checagem "ja existe -> devolve o existente" feita + -- na camada de repositorio (packages/database/src/repositories/documents.ts). + CREATE UNIQUE INDEX uq_document_generations_client_final + ON document_generations(template_version_id, client_registration_id) + WHERE status = 'final' AND client_registration_id IS NOT NULL; + CREATE UNIQUE INDEX uq_document_generations_reseller_final + ON document_generations(template_version_id, reseller_id) + WHERE status = 'final' AND reseller_id IS NOT NULL; + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS document_generations; + ALTER TABLE IF EXISTS document_templates DROP CONSTRAINT IF EXISTS fk_document_templates_current_version; + DROP TABLE IF EXISTS document_template_versions; + DROP TABLE IF EXISTS document_templates; + `); +}; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 5d19387..a148ef3 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -15,6 +15,7 @@ export * as supportTicketsRepo from "./repositories/support-tickets.js"; export * as hrTimeclockRepo from "./repositories/hr-timeclock.js"; export * as fiscalRepo from "./repositories/fiscal.js"; export * as billingRepo from "./repositories/billing.js"; +export * as documentsRepo from "./repositories/documents.js"; export { listLeads, getLead, @@ -163,3 +164,17 @@ export type { ListBillingRunsFilter, ListInvoicesFilter, } from "./repositories/billing.js"; +export type { + DocGroup, + TemplateVersionStatus, + GenerationStatus, + DocumentTemplate, + DocumentTemplateVersion, + DocumentTemplateWithVersions, + DocumentGeneration, + ListTemplatesFilter, + CreateTemplateInput, + UpdateDraftInput, + CreateGenerationInput, + ListGenerationsFilter, +} from "./repositories/documents.js"; diff --git a/packages/database/src/repositories/documents.ts b/packages/database/src/repositories/documents.ts new file mode 100644 index 0000000..7a856e2 --- /dev/null +++ b/packages/database/src/repositories/documents.ts @@ -0,0 +1,495 @@ +import { query, withTransaction } from "../pool.js"; + +/** + * Document Templates + Generation — eden.md module 4 "Templates de + * Documentos, Geração de PDF e Assinatura Eletrônica", sections 1.1-1.3 + * (lines 2210-2825). Only the "templates + generation" half of that + * module — Assinatura Eletrônica (sections 1.4-1.9) is explicitly + * deferred to a future dedicated leva (see the migration's header + * comment for the full rationale). + * + * Mirrors the conventions of contracts.ts (withTransaction for + * multi-statement writes that must commit/rollback together, custom + * *Error classes mapped to HTTP status in the service layer, snake_case + * rows). + * + * `html_content` on document_template_versions is always recomputed by + * the CALLER (apps/api/src/documents/editor-content-renderer.ts) and + * passed into createDraft/updateDraft as a `renderHtml` callback rather + * than imported here directly — this package must stay free of any + * dependency on the API app layer. + */ + +export type DocGroup = "cliente" | "revenda"; +export type TemplateVersionStatus = "draft" | "published"; +export type GenerationStatus = "generating" | "final" | "error"; + +export interface DocumentTemplate { + id: string; + created_at: Date; + updated_at: Date; + key: string; + name: string; + doc_group: DocGroup; + active: boolean; + current_version_id: string | null; + created_by: string | null; +} + +export interface DocumentTemplateVersion { + id: string; + template_id: string; + version: number; + status: TemplateVersionStatus; + editor_content: unknown; + html_content: string; + css_content: string | null; + page_config: Record; + created_by: string | null; + published_at: Date | null; + published_by: string | null; + created_at: Date; +} + +export interface DocumentTemplateWithVersions extends DocumentTemplate { + versions: DocumentTemplateVersion[]; +} + +export interface DocumentGeneration { + id: string; + created_at: Date; + template_id: string; + template_version_id: string; + client_registration_id: string | null; + reseller_id: string | null; + render_context: Record; + rendered_html: string | null; + pdf_s3_key: string | null; + pdf_sha256: string | null; + pdf_size_bytes: number | null; + status: GenerationStatus; + error_message: string | null; + generated_by: string | null; +} + +export class TemplateNotFoundError extends Error { + constructor(public readonly templateId: string) { + super(`Document template ${templateId} not found`); + this.name = "TemplateNotFoundError"; + } +} + +export class TemplateKeyConflictError extends Error { + constructor(public readonly key: string) { + super(`Document template key '${key}' already in use`); + this.name = "TemplateKeyConflictError"; + } +} + +/** eden.md §1.2 invariant: at most one draft version per template. */ +export class DraftAlreadyExistsError extends Error { + constructor(public readonly templateId: string) { + super(`Template ${templateId} already has a draft version`); + this.name = "DraftAlreadyExistsError"; + } +} + +export class DraftNotFoundError extends Error { + constructor(public readonly templateId: string) { + super(`Template ${templateId} has no draft version`); + this.name = "DraftNotFoundError"; + } +} + +function isUniqueViolation(err: unknown): boolean { + return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505"; +} + +// --------------------------------------------------------------------------- +// document_templates +// --------------------------------------------------------------------------- + +export interface ListTemplatesFilter { + docGroup?: DocGroup; + active?: boolean; +} + +export async function listTemplates(filter: ListTemplatesFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + if (filter.docGroup) { + params.push(filter.docGroup); + conditions.push(`doc_group = $${params.length}`); + } + if (filter.active !== undefined) { + params.push(filter.active); + conditions.push(`active = $${params.length}`); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM document_templates ${where} ORDER BY name ASC`, + params, + ); + return rows; +} + +export async function getTemplateById(id: string): Promise { + const { rows } = await query("SELECT * FROM document_templates WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export async function listTemplateVersions(templateId: string): Promise { + const { rows } = await query( + "SELECT * FROM document_template_versions WHERE template_id = $1 ORDER BY version DESC", + [templateId], + ); + return rows; +} + +export async function getTemplateWithVersions(id: string): Promise { + const template = await getTemplateById(id); + if (!template) return null; + const versions = await listTemplateVersions(id); + return { ...template, versions }; +} + +export interface CreateTemplateInput { + key: string; + name: string; + docGroup: DocGroup; + createdBy?: string | null; +} + +/** + * `current_version_id` always starts NULL — a template is never created + * already pointing at a version (eden.md §1.1); it only gets one via + * publishDraft(). + */ +export async function createTemplate(input: CreateTemplateInput): Promise { + try { + const { rows } = await query( + `INSERT INTO document_templates (key, name, doc_group, created_by) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [input.key, input.name, input.docGroup, input.createdBy ?? null], + ); + const created = rows[0]; + if (!created) throw new Error("failed to create document template"); + return created; + } catch (err) { + if (isUniqueViolation(err)) throw new TemplateKeyConflictError(input.key); + throw err; + } +} + +// --------------------------------------------------------------------------- +// document_template_versions — draft/publish lifecycle (eden.md §1.2) +// --------------------------------------------------------------------------- + +const EMPTY_DOC_CONTENT: unknown = { type: "doc", content: [{ type: "paragraph", content: [] }] }; + +/** + * Creates a new draft: clones editor_content/css_content/page_config + * from the currently published version (current_version_id), or starts + * from an empty Tiptap document if the template was never published. + * `version` is always MAX(version)+1 for the template — numbers are + * never reused, even though old versions are never deleted. Throws + * TemplateNotFoundError / DraftAlreadyExistsError instead of silently + * no-oping; the service layer maps these to 404/409. + */ +export async function createDraft( + templateId: string, + createdBy: string | null, + renderHtml: (editorContent: unknown) => string, +): Promise { + return withTransaction(async (client) => { + const { rows: templateRows } = await client.query( + "SELECT * FROM document_templates WHERE id = $1 FOR UPDATE", + [templateId], + ); + const template = templateRows[0]; + if (!template) throw new TemplateNotFoundError(templateId); + + const { rows: draftRows } = await client.query( + "SELECT id FROM document_template_versions WHERE template_id = $1 AND status = 'draft'", + [templateId], + ); + if (draftRows[0]) throw new DraftAlreadyExistsError(templateId); + + let editorContent: unknown = EMPTY_DOC_CONTENT; + let cssContent: string | null = null; + let pageConfig: Record = {}; + + if (template.current_version_id) { + const { rows: pubRows } = await client.query<{ + editor_content: unknown; + css_content: string | null; + page_config: Record; + }>( + "SELECT editor_content, css_content, page_config FROM document_template_versions WHERE id = $1", + [template.current_version_id], + ); + const published = pubRows[0]; + if (published) { + editorContent = published.editor_content; + cssContent = published.css_content; + pageConfig = published.page_config; + } + } + + const { rows: verRows } = await client.query<{ next_version: string }>( + "SELECT COALESCE(MAX(version), 0) + 1 AS next_version FROM document_template_versions WHERE template_id = $1", + [templateId], + ); + const nextVersion = Number(verRows[0]?.next_version ?? 1); + const htmlContent = renderHtml(editorContent); + + const { rows } = await client.query( + `INSERT INTO document_template_versions + (template_id, version, status, editor_content, html_content, css_content, page_config, created_by) + VALUES ($1, $2, 'draft', $3, $4, $5, $6, $7) + RETURNING *`, + [ + templateId, + nextVersion, + JSON.stringify(editorContent), + htmlContent, + cssContent, + JSON.stringify(pageConfig), + createdBy, + ], + ); + const created = rows[0]; + if (!created) throw new Error("failed to create draft version"); + return created; + }); +} + +export interface UpdateDraftInput { + editorContent?: unknown; + cssContent?: string | null; + pageConfig?: Record; +} + +/** + * Overwrites the existing draft only — never creates one, never touches + * a published version (eden.md §1.2 step 2). `undefined` fields in + * `patch` keep the draft's current value; only fields explicitly present + * are replaced (so `cssContent: null` can clear it, distinct from + * omitting the field). + */ +export async function updateDraft( + templateId: string, + patch: UpdateDraftInput, + renderHtml: (editorContent: unknown) => string, +): Promise { + return withTransaction(async (client) => { + const { rows: templateRows } = await client.query("SELECT id FROM document_templates WHERE id = $1", [ + templateId, + ]); + if (!templateRows[0]) throw new TemplateNotFoundError(templateId); + + const { rows: draftRows } = await client.query( + "SELECT * FROM document_template_versions WHERE template_id = $1 AND status = 'draft' FOR UPDATE", + [templateId], + ); + const draft = draftRows[0]; + if (!draft) throw new DraftNotFoundError(templateId); + + const editorContent = patch.editorContent !== undefined ? patch.editorContent : draft.editor_content; + const cssContent = patch.cssContent !== undefined ? patch.cssContent : draft.css_content; + const pageConfig = patch.pageConfig !== undefined ? patch.pageConfig : draft.page_config; + const htmlContent = renderHtml(editorContent); + + const { rows } = await client.query( + `UPDATE document_template_versions + SET editor_content = $2, html_content = $3, css_content = $4, page_config = $5 + WHERE id = $1 + RETURNING *`, + [draft.id, JSON.stringify(editorContent), htmlContent, cssContent, JSON.stringify(pageConfig)], + ); + const updated = rows[0]; + if (!updated) throw new Error("failed to update draft version"); + return updated; + }); +} + +/** + * Transactionally marks the draft as published (stamping published_at/ + * published_by) and repoints document_templates.current_version_id at + * it — eden.md §1.2 step 3. From this point the version is immutable; + * no route in this leva ever edits a version with status='published'. + */ +export async function publishDraft(templateId: string, publishedBy: string | null): Promise { + return withTransaction(async (client) => { + const { rows: templateRows } = await client.query("SELECT id FROM document_templates WHERE id = $1 FOR UPDATE", [ + templateId, + ]); + if (!templateRows[0]) throw new TemplateNotFoundError(templateId); + + const { rows: draftRows } = await client.query( + "SELECT * FROM document_template_versions WHERE template_id = $1 AND status = 'draft' FOR UPDATE", + [templateId], + ); + const draft = draftRows[0]; + if (!draft) throw new DraftNotFoundError(templateId); + + const { rows: publishedRows } = await client.query( + `UPDATE document_template_versions + SET status = 'published', published_at = now(), published_by = $2 + WHERE id = $1 + RETURNING *`, + [draft.id, publishedBy], + ); + const published = publishedRows[0]; + if (!published) throw new Error("failed to publish draft version"); + + await client.query("UPDATE document_templates SET current_version_id = $2 WHERE id = $1", [ + templateId, + draft.id, + ]); + + return published; + }); +} + +// --------------------------------------------------------------------------- +// document_generations (eden.md §1.3) +// --------------------------------------------------------------------------- + +/** + * Resolves the version that a real (non-draft) generation must use — + * always `document_templates.current_version_id` via direct lookup, + * NEVER a draft (eden.md §2.4: a draft only "shadows" the published + * version in internal preview/test screens, never in a real generation). + * Returns null when the template has never been published (the service + * layer maps this to 404), and throws TemplateNotFoundError if the + * template id itself doesn't exist. + */ +export async function getPublishedVersionForTemplate( + templateId: string, +): Promise<{ template: DocumentTemplate; version: DocumentTemplateVersion } | null> { + const template = await getTemplateById(templateId); + if (!template) throw new TemplateNotFoundError(templateId); + if (!template.current_version_id) return null; + + const { rows } = await query( + "SELECT * FROM document_template_versions WHERE id = $1", + [template.current_version_id], + ); + const version = rows[0]; + if (!version) return null; + return { template, version }; +} + +export interface CreateGenerationInput { + templateId: string; + templateVersionId: string; + clientRegistrationId?: string | null; + resellerId?: string | null; + renderContext: Record; + renderedHtml: string; + generatedBy?: string | null; +} + +async function findFinalGeneration( + templateVersionId: string, + source: { clientRegistrationId?: string | null; resellerId?: string | null }, +): Promise { + if (source.clientRegistrationId) { + const { rows } = await query( + `SELECT * FROM document_generations + WHERE template_version_id = $1 AND client_registration_id = $2 AND status = 'final' + LIMIT 1`, + [templateVersionId, source.clientRegistrationId], + ); + return rows[0] ?? null; + } + const { rows } = await query( + `SELECT * FROM document_generations + WHERE template_version_id = $1 AND reseller_id = $2 AND status = 'final' + LIMIT 1`, + [templateVersionId, source.resellerId ?? null], + ); + return rows[0] ?? null; +} + +async function insertGeneration(input: CreateGenerationInput): Promise { + const { rows } = await query( + `INSERT INTO document_generations + (template_id, template_version_id, client_registration_id, reseller_id, + render_context, rendered_html, status, generated_by) + VALUES ($1, $2, $3, $4, $5, $6, 'final', $7) + RETURNING *`, + [ + input.templateId, + input.templateVersionId, + input.clientRegistrationId ?? null, + input.resellerId ?? null, + JSON.stringify(input.renderContext), + input.renderedHtml, + input.generatedBy ?? null, + ], + ); + const created = rows[0]; + if (!created) throw new Error("failed to create document generation"); + return created; +} + +/** + * Simplified idempotency (eden.md §1.3 describes a richer rule with + * `force`/`extra_fields` — out of scope for this leva; only the base + * case is implemented): the same (template_version_id, origin) already + * `status='final'` returns the existing row instead of creating another. + * The optimistic read-then-insert below is backed by the partial unique + * indexes uq_document_generations_{client,reseller}_final from the + * migration — on a race between two concurrent requests, the loser's + * insert raises a unique_violation (23505) and re-reads to return the + * winner's row instead of erroring. + */ +export async function createGenerationIdempotent( + input: CreateGenerationInput, +): Promise<{ generation: DocumentGeneration; deduped: boolean }> { + const existing = await findFinalGeneration(input.templateVersionId, input); + if (existing) return { generation: existing, deduped: true }; + + try { + const created = await insertGeneration(input); + return { generation: created, deduped: false }; + } catch (err) { + if (isUniqueViolation(err)) { + const retried = await findFinalGeneration(input.templateVersionId, input); + if (retried) return { generation: retried, deduped: true }; + } + throw err; + } +} + +export interface ListGenerationsFilter { + templateId?: string; + clientRegistrationId?: string; + resellerId?: string; +} + +export async function listGenerations(filter: ListGenerationsFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + if (filter.templateId) { + params.push(filter.templateId); + conditions.push(`template_id = $${params.length}`); + } + if (filter.clientRegistrationId) { + params.push(filter.clientRegistrationId); + conditions.push(`client_registration_id = $${params.length}`); + } + if (filter.resellerId) { + params.push(filter.resellerId); + conditions.push(`reseller_id = $${params.length}`); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const { rows } = await query( + `SELECT * FROM document_generations ${where} ORDER BY created_at DESC`, + params, + ); + return rows; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97bfa4d..4233045 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,12 +142,18 @@ importers: apps/reseller-web: dependencies: + '@eden/ui': + specifier: workspace:* + version: link:../../packages/ui react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.28.1 + version: 6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.18 @@ -176,12 +182,18 @@ importers: apps/subscriber-web: dependencies: + '@eden/ui': + specifier: workspace:* + version: link:../../packages/ui react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.28.1 + version: 6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.18