diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 50dde1034..1e0908da6 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; +import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; @@ -67,6 +68,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { SupportContentSeeder } from "./seed/support-content.seeder"; // import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; @@ -220,6 +222,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, ContractTemplatesModule, + SupportContentModule, OtpModule, HealthModule, RuleEngineModule, @@ -260,6 +263,7 @@ if (!process.env.APPLICATION_NAME) { EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + SupportContentSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, FreightNotificationPermissionsSeeder, @@ -291,6 +295,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly supportContentSeeder: SupportContentSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, @@ -349,6 +354,10 @@ export class AppModule implements OnApplicationBootstrap { // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Portal help/FAQ/legal copy — keep enabled. Idempotent by emptiness, so + // it fills an empty table once and never touches admin edits afterwards. + await this.supportContentSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Dire Dawa). Idempotent; creates no yards. // await this.yardFacilitiesSeeder.run(); diff --git a/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts new file mode 100644 index 000000000..168ee2c6d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3350000000000-SupportContent.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Editable customer-facing copy for the portal's public pages (/help, /faq, + * /terms, /privacy) plus the shared support-contact block, with an append-only + * version log behind it. + * + * `payload` is opaque jsonb: the five documents have genuinely different shapes + * and the help page's blocks change with the copy, so typed columns would mean + * a migration per wording tweak. The shape is enforced by per-slug DTOs on + * write instead. + * + * No rows are inserted here — `SupportContentSeeder` fills the table on first + * boot and skips whenever it is non-empty, so a redeploy never overwrites + * admin edits the way a migration-embedded INSERT eventually would. + */ +export class SupportContent3350000000000 implements MigrationInterface { + name = "SupportContent3350000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug varchar(32) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + version integer NOT NULL DEFAULT 1, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_documents_slug + ON freight.support_documents (slug); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.support_document_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + document_id uuid NOT NULL + REFERENCES freight.support_documents(id) ON DELETE CASCADE, + version integer NOT NULL, + payload jsonb NOT NULL, + actor_id uuid, + note varchar(255), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + + // Closes the concurrent-save race: two editors saving at once cannot both + // claim the same version number. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_support_doc_version + ON freight.support_document_versions (document_id, version); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_support_doc_versions_document + ON freight.support_document_versions (document_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.support_document_versions;`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.support_documents;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts new file mode 100644 index 000000000..5220d3a6c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3360000000000-SupportHelpSections.ts @@ -0,0 +1,112 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Converts the HELP document from its original fixed-block shape + * (`video` / `chat` / `channels` / `topics` / `checklist`) to the free-form + * `sections[]` builder, where every block is a heading plus markdown plus + * attached media. + * + * Only rows still in the old shape are touched — detected by the presence of a + * `channels` key — so this is a no-op on any environment seeded after the + * change, and re-running it does nothing. + * + * The payload literal is inlined rather than imported from + * `SUPPORT_CONTENT_DEFAULTS`: a migration must keep doing the same thing + * forever, and that constant will keep moving. + * + * The rewrite also bumps `version` and writes a matching history row. The live + * row's version always having a matching entry in + * `support_document_versions` is the invariant the history list and rollback + * both depend on, and a silent payload swap would break it. + */ +const HELP_SECTIONS = [ + { + id: "help-walkthrough", + heading: "Portal walkthrough", + body: "A guided tour of the portal — registering your company, raising a booking against a contract, and settling an invoice.", + media: [ + { + id: "help-walkthrough-video", + kind: "video", + src: "/assets/edr-portal-guide.webm", + caption: null, + }, + ], + }, + { + id: "help-chat", + heading: "Chat with our team", + body: "Signed-in customers can open a support conversation from the headset button at the bottom right of every portal page. You can send screenshots and documents in the chat, and replies appear there and as a notification.\n\n[Open the portal](/portal)", + media: [], + }, + { + id: "help-contact", + heading: "Contact us", + body: "- **Email** — [{{supportEmail}}](mailto:{{supportEmail}}). Best for document issues and anything needing an attachment.\n- **Phone** — [{{supportPhone}}](tel:{{supportPhoneTel}}). Best for urgent problems with cargo already in transit.\n- **Head office** — {{supportOffice}}. Walk-in support during working hours.\n- **Support hours** — {{supportHours}}. Outside these hours, email us and we reply the next working day.", + media: [], + }, + { + id: "help-topics", + heading: "Common topics", + body: "- **[Account & onboarding](/faq)** — registering your company, uploading your trade licence and TIN, and getting an operational profile approved.\n- **[Contracts](/faq)** — requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.\n- **[Bookings & tracking](/faq)** — raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.\n- **[Invoices & payments](/faq)** — finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", + media: [], + }, + { + id: "help-checklist", + heading: "What to include when you contact us", + body: "- Your company name and the email you sign in with.\n- The reference of the contract, booking or invoice involved.\n- What you expected to happen and what happened instead.\n- A screenshot of any error message the portal showed.", + media: [], + }, +]; + +export class SupportHelpSections3360000000000 implements MigrationInterface { + name = "SupportHelpSections3360000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const rows: { id: string; version: number; payload: Record }[] = + await queryRunner.query(` + SELECT id, version, payload + FROM freight.support_documents + WHERE slug = 'HELP' AND payload ? 'channels' + `); + + for (const row of rows) { + const payload = { + title: row.payload.title ?? "Help & Support", + subtitle: + row.payload.subtitle ?? + "Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly.", + sections: HELP_SECTIONS, + }; + const version = row.version + 1; + + await queryRunner.query( + `UPDATE freight.support_documents + SET payload = $1::jsonb, version = $2, updated_at = now() + WHERE id = $3`, + [JSON.stringify(payload), version, row.id], + ); + + await queryRunner.query( + `INSERT INTO freight.support_document_versions + (document_id, version, payload, actor_id, note) + VALUES ($1, $2, $3::jsonb, NULL, $4)`, + [ + row.id, + version, + JSON.stringify(payload), + "Converted help page to free-form sections", + ], + ); + } + } + + /** + * Not reversible: the old fixed blocks cannot be recovered from markdown + * sections an editor may since have rewritten. The version history holds the + * pre-conversion payload if it is ever genuinely needed. + */ + public async down(): Promise { + // no-op + } +} diff --git a/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts new file mode 100644 index 000000000..fdfffe8be --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-content/dto/support-content.dto.ts @@ -0,0 +1,311 @@ +import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsObject, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, + ValidateNested, +} from "class-validator"; + +/** + * Markdown bodies are safe on read — the portal renders them with + * `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The + * fields worth validating are these: they land in `href`/`src` attributes and + * bypass markdown entirely, which is where a `javascript:` URL would actually + * execute. + * + * Placeholders survive the check because they sit after the scheme + * (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`). + */ +const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/; +const LINK_MESSAGE = + "$property must start with http(s)://, mailto:, tel: or /"; + +/** + * A media source is either an uploaded MinIO object key, a same-origin path, or + * an https URL. Anything else — notably `javascript:` — is refused, since this + * value lands in an ``/`