import { Component, OnInit, AfterViewInit, signal } from '@angular/core'; import { CommonModule, KeyValuePipe } from '@angular/common'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideZap, lucideShield, lucideUsers, lucideCreditCard, lucideBell, lucideSettings, lucideBookOpen, lucideCode, lucideArrowRight, lucideExternalLink, lucideChevronRight, lucideChevronDown, lucidePlay, lucideCopy, lucideCheck, lucideMessageSquare, lucideHome, lucideFileJson, lucideSearch, lucideFilter, } from '@ng-icons/lucide'; declare var mermaid: any; interface Step { id: number; title: string; description: string; icon: string; color: string; isOpen: boolean; endpoint?: { method: string; path: string; }; requestBody?: string; responseBody?: string; note?: { type: 'warning' | 'success' | 'info' | 'purple'; text: string; }; headers?: string; } interface SwaggerEndpoint { method: string; path: string; summary: string; description?: string; operationId: string; tags: string[]; parameters?: any[]; requestBody?: any; responses?: any; security?: any[]; } interface SwaggerTag { name: string; endpoints: SwaggerEndpoint[]; isOpen: boolean; } @Component({ selector: 'app-documentation', standalone: true, imports: [CommonModule, NgIcon, KeyValuePipe], viewProviders: [ provideIcons({ lucideZap, lucideShield, lucideUsers, lucideCreditCard, lucideBell, lucideSettings, lucideBookOpen, lucideCode, lucideArrowRight, lucideExternalLink, lucideChevronRight, lucideChevronDown, lucidePlay, lucideCopy, lucideCheck, lucideMessageSquare, lucideHome, lucideFileJson, lucideSearch, lucideFilter, }), ], templateUrl: './documentation.html', styleUrl: './documentation.scss', }) export class Documentation implements OnInit, AfterViewInit { activeSection = signal('overview'); activeApiTab = signal('otp'); copiedCode = signal(null); swaggerSearch = signal(''); selectedSwaggerEndpoint = signal(null); sections = [ { id: 'overview', label: "Vue d'ensemble", icon: 'lucideBookOpen' }, { id: 'flow', label: 'Call Flow', icon: 'lucidePlay' }, { id: 'steps', label: "Étapes d'intégration", icon: 'lucideArrowRight' }, { id: 'api', label: 'API Reference', icon: 'lucideCode' }, ]; apiTabs = [ { id: 'otp', label: 'OTP Challenge' }, { id: 'subscriptions', label: 'Subscriptions' }, { id: 'payments', label: 'Payments' }, { id: 'operators', label: 'Operators' }, { id: 'swagger', label: 'Swagger API' }, ]; features = [ { icon: 'lucideShield', title: 'OTP Challenge', desc: 'Authentification sécurisée par SMS', color: 'indigo', }, { icon: 'lucideUsers', title: 'Subscriptions', desc: 'Gestion des abonnements récurrents', color: 'green', }, { icon: 'lucideCreditCard', title: 'Payments', desc: 'Facturation et charges', color: 'blue', }, { icon: 'lucideBell', title: 'Webhooks', desc: 'Notifications en temps réel', color: 'purple', }, ]; steps: Step[] = [ { id: 1, title: 'Initier le Challenge OTP', description: "Envoi d'un code OTP au numéro de téléphone de l'utilisateur", icon: 'lucideShield', color: 'indigo', isOpen: true, endpoint: { method: 'POST', path: '/api/v1/otp-challenge/initiate' }, requestBody: `{ "msisdn": "225XXXXXXXXX", "channel": "SMS", "serviceId": "your-service-id", "merchantId": "your-merchant-id" }`, responseBody: `{ "challengeId": "chg_abc123xyz", "status": "PENDING", "expiresAt": "2024-01-15T10:30:00Z", "channel": "SMS" }`, note: { type: 'warning', text: "⚠️ Note: Le code OTP expire après 15 minutes. Maximum 3 tentatives de validation.", }, }, { id: 2, title: 'Valider le Code OTP', description: "Vérification du code saisi par l'utilisateur et récupération du token ISE2", icon: 'lucideCheck', color: 'green', isOpen: false, endpoint: { method: 'POST', path: '/api/v1/otp-challenge/{challengeId}/verify' }, requestBody: `{ "otp": "123456" }`, responseBody: `{ "challengeId": "chg_abc123xyz", "status": "VALIDATED", "ise2Token": "eyJhbGciOiJSUzI1NiIs...", "validatedAt": "2024-01-15T10:28:45Z" }`, note: { type: 'success', text: "✅ Important: Conservez le ise2Token - il sera nécessaire pour la souscription et les charges.", }, }, { id: 3, title: 'Créer une Souscription', description: "Enregistrement de l'abonnement utilisateur au service", icon: 'lucideUsers', color: 'blue', isOpen: false, endpoint: { method: 'POST', path: '/api/v1/subscriptions' }, requestBody: `{ "userToken": "ise2_token_from_otp_validation", "userAlias": "225XXXXXXXXX", "planId": 1, "callbackUrl": "https://your-domain.com/webhooks/subscription", "metadata": { "customerId": "cust_123", "source": "mobile_app" } }`, responseBody: `{ "id": 456, "status": "ACTIVE", "planId": 1, "startDate": "2024-01-15", "nextPaymentDate": "2024-02-15", "createdAt": "2024-01-15T10:29:00Z" }`, }, { id: 4, title: 'Recevoir les Webhooks', description: 'Notifications automatiques des événements (souscription, paiement, etc.)', icon: 'lucideBell', color: 'purple', isOpen: false, responseBody: `{ "event": "subscription.created", "timestamp": "2024-01-15T10:29:05Z", "data": { "subscriptionId": 456, "ise2": "ise2_token", "productId": "prod_abc", "serviceId": "svc_xyz", "status": "ACTIVE", "msisdn": "225XXXXXXXXX" }, "signature": "sha256=..." }`, note: { type: 'purple', text: "🔔 Types d'événements: subscription.created, subscription.cancelled, payment.success, payment.failed", }, }, { id: 5, title: 'Effectuer une Charge', description: "Facturation du montant sur le compte mobile de l'utilisateur", icon: 'lucideCreditCard', color: 'orange', isOpen: false, endpoint: { method: 'POST', path: '/api/v1/payments/charge' }, headers: `X-Merchant-ID: your-merchant-id X-COUNTRY: CI X-OPERATOR: ORANGE`, requestBody: `{ "userToken": "ise2_token", "amount": 500, "currency": "XOF", "description": "Abonnement Premium - Janvier 2024", "reference": "PAY-2024-001-ABC", "subscriptionId": 456, "callbackUrl": "https://your-domain.com/webhooks/payment", "metadata": { "orderId": "ord_789" } }`, responseBody: `{ "id": "pay_xyz789", "status": "SUCCESS", "amount": 500, "currency": "XOF", "reference": "PAY-2024-001-ABC", "operatorReference": "OPR-123456", "completedAt": "2024-01-15T10:30:15Z" }`, }, { id: 6, title: 'Envoyer un SMS de Confirmation', description: "Notification SMS à l'utilisateur confirmant l'activation du service", icon: 'lucideMessageSquare', color: 'pink', isOpen: false, endpoint: { method: 'POST', path: '/smsmessaging/service/mea/v1/outbound/{senderMsisdn}/requests' }, requestBody: `{ "outboundSMSMessageRequest": { "address": ["tel:+225XXXXXXXXX"], "senderAddress": "tel:+225YYYYYYYYYY", "outboundSMSTextMessage": { "message": "Félicitations! Votre abonnement Premium est maintenant actif." } } }`, }, ]; subscriptionStatuses = ['ACTIVE', 'TRIAL', 'PENDING', 'SUSPENDED', 'EXPIRED', 'CANCELLED']; // Swagger data parsed from the JSON swaggerTags: SwaggerTag[] = []; swaggerInfo = { title: 'Payment Hub API', description: 'Unified DCB Payment Aggregation Platform', version: '1.0.0' }; mermaidDiagram = `sequenceDiagram participant USER as 👤 Utilisateur participant Partner as 🏪 Partner participant HUB as ⚡ HUB DCB participant DigiPay as 📱 DigiPay rect rgb(99, 102, 241, 0.1) Note over Partner,DigiPay: 🔐 Phase 1: OTP Challenge Partner->>HUB: POST /api/v1/otp-challenge/initiate HUB->>DigiPay: POST /challenge/v1/challenges DigiPay-->>HUB: challengeId + status HUB-->>Partner: challengeId + status end DigiPay->>USER: 📨 SMS avec code OTP USER->>Partner: Saisie du code OTP rect rgb(34, 197, 94, 0.1) Note over Partner,DigiPay: ✅ Phase 2: Validation OTP Partner->>HUB: POST /api/v1/otp-challenge/{id}/verify HUB->>DigiPay: POST /challenge/v1/challenges/{id} DigiPay-->>HUB: ise2 token HUB-->>Partner: ise2 token end rect rgb(59, 130, 246, 0.1) Note over Partner,DigiPay: 📋 Phase 3: Souscription Partner->>HUB: POST /api/v1/subscriptions HUB->>DigiPay: POST /digipay_sub/productOrder/ DigiPay-->>HUB: subscription response HUB-->>Partner: subscription details end rect rgb(168, 85, 247, 0.1) Note over Partner,DigiPay: 🔔 Notification Webhook DigiPay->>HUB: Subscription notification HUB->>Partner: Webhook callback end rect rgb(249, 115, 22, 0.1) Note over Partner,DigiPay: 💳 Phase 4: Facturation Partner->>HUB: POST /api/v1/payments/charge HUB->>DigiPay: POST /payment/mea/v1/.../transactions/amount DigiPay-->>HUB: payment response HUB-->>Partner: payment confirmation end rect rgb(236, 72, 153, 0.1) Note over Partner,DigiPay: 📱 Phase 5: SMS Confirmation Partner->>HUB: POST /smsmessaging/.../requests HUB->>DigiPay: Send SMS DigiPay->>USER: 📨 SMS de confirmation end HUB->>USER: 🎉 Service activé`; ngOnInit(): void { this.parseSwaggerSpec(); } ngAfterViewInit(): void { this.initMermaid(); } private parseSwaggerSpec(): void { // Parse the Swagger JSON spec const swaggerSpec = { "openapi": "3.0.0", "paths": { "/api/v1/operators": { "get": { "operationId": "OperatorsController_getOperatorStatistics", "parameters": [ {"name": "operatorCode", "required": true, "in": "path", "schema": {"type": "string"}}, {"name": "period", "required": false, "in": "query", "schema": {"enum": ["daily", "weekly", "monthly", "yearly"], "type": "string"}}, {"name": "startDate", "required": false, "in": "query", "schema": {"format": "date-time", "type": "string"}}, {"name": "endDate", "required": false, "in": "query", "schema": {"format": "date-time", "type": "string"}}, {"name": "active", "required": false, "in": "query", "schema": {"type": "boolean"}}, {"name": "country", "required": false, "in": "query", "schema": {}} ], "responses": {"200": {"description": "List of operators"}}, "security": [{"bearer": []}], "summary": "List all available operators", "tags": ["operators"] } }, "/api/v1/operators/{operatorCode}/health": { "get": { "operationId": "OperatorsController_getOperatorHealth", "parameters": [{"name": "operatorCode", "required": true, "in": "path", "schema": {"type": "string"}}], "responses": {"200": {"description": "Operator health metrics"}}, "security": [{"bearer": []}], "summary": "Get operator health metrics", "tags": ["operators"] } }, "/api/v1/operators/detect/{msisdn}": { "get": { "operationId": "OperatorsController_detectOperator", "parameters": [{"name": "msisdn", "required": true, "in": "path", "schema": {"type": "string"}}], "responses": {"200": {"description": "Detected operator information"}}, "security": [{"bearer": []}], "summary": "Detect operator from MSISDN", "tags": ["operators"] } }, "/api/v1/payments/charge": { "post": { "operationId": "PaymentsController_createCharge", "parameters": [ {"name": "X-Merchant-ID", "required": true, "in": "header", "schema": {"type": "string"}}, {"name": "X-COUNTRY", "required": true, "in": "header", "schema": {"type": "string"}}, {"name": "X-OPERATOR", "required": true, "in": "header", "schema": {"type": "string"}} ], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChargeDto"}}}}, "responses": { "201": {"description": "Payment created successfully"}, "400": {"description": "Bad request"}, "401": {"description": "Unauthorized"} }, "security": [{"bearer": []}], "summary": "Create a new charge", "tags": ["payments"] } }, "/api/v1/payments/{paymentId}/refund": { "post": { "operationId": "PaymentsController_refundPayment", "parameters": [{"name": "paymentId", "required": true, "in": "path", "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefundDto"}}}}, "responses": { "200": {"description": "Refund processed successfully"}, "404": {"description": "Payment not found"} }, "security": [{"bearer": []}], "summary": "Refund a payment", "tags": ["payments"] } }, "/api/v1/payments/{paymentId}": { "get": { "operationId": "PaymentsController_getPayment", "parameters": [{"name": "paymentId", "required": true, "in": "path", "schema": {"type": "number"}}], "responses": { "200": {"description": "Payment details retrieved"}, "404": {"description": "Payment not found"} }, "security": [{"bearer": []}], "summary": "Get payment details", "tags": ["payments"] } }, "/api/v1/payments/reference/{reference}": { "get": { "operationId": "PaymentsController_getPaymentByReference", "parameters": [{"name": "reference", "required": true, "in": "path", "schema": {"type": "string"}}], "responses": {"200": {"description": "Payment details retrieved"}}, "security": [{"bearer": []}], "summary": "Get payment by reference", "tags": ["payments"] } }, "/api/v1/payments": { "get": { "description": "Retrieve payments with optional filters on status, type, dates, amounts, etc.", "operationId": "PaymentsController_getAll", "parameters": [ {"name": "page", "required": false, "in": "query", "description": "Page number", "schema": {"minimum": 1, "default": 1, "type": "number"}}, {"name": "limit", "required": false, "in": "query", "description": "Number of items per page", "schema": {"minimum": 1, "maximum": 100, "default": 10, "type": "number"}}, {"name": "type", "required": false, "in": "query", "description": "Filter by payment type", "schema": {"type": "string", "enum": ["MM", "BANK", "CHEQUE"]}}, {"name": "status", "required": false, "in": "query", "description": "Filter by transaction status", "schema": {"type": "string", "enum": ["SUCCESS", "FAILED", "PENDING"]}}, {"name": "merchantPartnerId", "required": false, "in": "query", "description": "Filter by merchant partner ID", "schema": {"type": "number"}}, {"name": "currency", "required": false, "in": "query", "description": "Filter by currency code", "schema": {"example": "XOF", "type": "string"}}, {"name": "amountMin", "required": false, "in": "query", "description": "Filter payments with amount >= this value", "schema": {"type": "number"}}, {"name": "amountMax", "required": false, "in": "query", "description": "Filter payments with amount <= this value", "schema": {"type": "number"}}, {"name": "createdFrom", "required": false, "in": "query", "description": "Filter payments created from this date", "schema": {"type": "string"}}, {"name": "createdTo", "required": false, "in": "query", "description": "Filter payments created until this date", "schema": {"type": "string"}}, {"name": "sortBy", "required": false, "in": "query", "description": "Sort field", "schema": {"default": "createdAt", "type": "string", "enum": ["createdAt", "completedAt", "amount"]}}, {"name": "sortOrder", "required": false, "in": "query", "description": "Sort order", "schema": {"default": "desc", "type": "string", "enum": ["asc", "desc"]}} ], "responses": {"200": {"description": "Paginated list of payments"}}, "summary": "Get payment list with pagination and filters", "tags": ["payments"] } }, "/api/v1/payments/{paymentId}/retry": { "post": { "operationId": "PaymentsController_retryPayment", "parameters": [{"name": "paymentId", "required": true, "in": "path", "schema": {"type": "string"}}], "responses": { "200": {"description": "Payment retry initiated"}, "400": {"description": "Payment cannot be retried"} }, "security": [{"bearer": []}], "summary": "Retry a failed payment", "tags": ["payments"] } }, "/api/v1/subscriptions": { "post": { "operationId": "SubscriptionsController_create", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateSubscriptionDto"}}}}, "responses": {"201": {"description": "Subscription created successfully"}}, "summary": "Create subscription", "tags": ["subscriptions"] }, "get": { "operationId": "SubscriptionsController_getAll", "parameters": [ {"name": "page", "required": false, "in": "query", "description": "Page number", "schema": {"minimum": 1, "default": 1, "type": "number"}}, {"name": "limit", "required": false, "in": "query", "description": "Number of items per page", "schema": {"minimum": 1, "maximum": 100, "default": 10, "type": "number"}}, {"name": "status", "required": false, "in": "query", "description": "Filter by subscription status", "schema": {"type": "string", "enum": ["ACTIVE", "TRIAL", "PENDING", "SUSPENDED", "EXPIRED", "CANCELLED"]}}, {"name": "periodicity", "required": false, "in": "query", "description": "Filter by periodicity", "schema": {"type": "string", "enum": ["Daily", "Weekly", "Monthly", "OneTime"]}}, {"name": "serviceId", "required": false, "in": "query", "description": "Filter by service ID", "schema": {"type": "number"}} ], "responses": {"200": {"description": "Paginated list of subscriptions"}}, "summary": "Get subscription list with pagination", "tags": ["subscriptions"] } }, "/api/v1/subscriptions/{id}": { "get": { "operationId": "SubscriptionsController_get", "parameters": [{"name": "id", "required": true, "in": "path", "schema": {"type": "number"}}], "responses": {"200": {"description": "Subscription details"}}, "summary": "Get subscription details", "tags": ["subscriptions"] }, "delete": { "operationId": "SubscriptionsController_cancel", "parameters": [{"name": "id", "required": true, "in": "path", "schema": {"type": "number"}}], "responses": {"200": {"description": "Subscription cancelled"}}, "summary": "Cancel subscription", "tags": ["subscriptions"] } }, "/api/v1/otp-challenge/initiate": { "post": { "description": "Envoie un code OTP au numéro de téléphone spécifié via SMS, USSD ou IVR", "operationId": "OtpChallengeController_initiateChallenge", "parameters": [ {"name": "X-Merchant-ID", "required": true, "in": "header", "description": "Identifiant du merchant", "schema": {"type": "string"}}, {"name": "X-API-Key", "required": true, "in": "header", "description": "Clé API du merchant", "schema": {"type": "string"}} ], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OtpChallengeRequestDto"}}}}, "responses": { "201": {"description": "Challenge OTP initié avec succès"}, "400": {"description": "Requête invalide"}, "500": {"description": "Erreur serveur"} }, "summary": "Initier un challenge OTP", "tags": ["OTP Challenge"] } }, "/api/v1/otp-challenge/{challengeId}/verify": { "post": { "description": "Vérifie le code OTP entré par l'utilisateur", "operationId": "OtpChallengeController_verifyOtp", "parameters": [ {"name": "challengeId", "required": true, "in": "path", "description": "Identifiant du challenge", "schema": {"type": "string"}}, {"name": "X-Merchant-ID", "required": true, "in": "header", "description": "Identifiant du merchant", "schema": {"type": "string"}}, {"name": "X-API-Key", "required": true, "in": "header", "description": "Clé API du merchant", "schema": {"type": "string"}} ], "responses": { "200": {"description": "Code OTP vérifié avec succès"}, "400": {"description": "Code OTP invalide"}, "404": {"description": "Challenge non trouvé"} }, "summary": "Vérifier un code OTP", "tags": ["OTP Challenge"] } } }, "info": { "title": "Payment Hub API", "description": "Unified DCB Payment Aggregation Platform", "version": "1.0.0" }, "components": { "schemas": { "ChargeDto": { "type": "object", "properties": { "userToken": {"type": "string", "description": "User token from authentication"}, "amount": {"type": "number", "description": "Amount to charge"}, "currency": {"type": "string", "description": "Currency code (XOF, XAF, USD, etc.)"}, "description": {"type": "string", "description": "Payment description"}, "reference": {"type": "string", "description": "Unique payment reference"}, "subscriptionId": {"type": "number", "description": "Subscription ID if recurring"}, "callbackUrl": {"type": "string", "description": "Callback URL for notifications"}, "metadata": {"type": "object", "description": "Additional metadata"} }, "required": ["userToken", "amount", "currency", "description"] }, "RefundDto": { "type": "object", "properties": { "amount": {"type": "number", "description": "Amount to refund (partial refund)"}, "reason": {"type": "string", "description": "Reason for refund"}, "metadata": {"type": "object", "description": "Additional metadata"} }, "required": ["reason"] }, "CreateSubscriptionDto": { "type": "object", "properties": { "userToken": {"type": "string"}, "userAlias": {"type": "string"}, "planId": {"type": "number"}, "callbackUrl": {"type": "string"}, "metadata": {"type": "object"} }, "required": ["userToken", "userAlias", "planId"] }, "OtpChallengeRequestDto": { "type": "object", "properties": { "msisdn": {"type": "string", "description": "Phone number"}, "channel": {"type": "string", "enum": ["SMS", "USSD", "IVR"]}, "serviceId": {"type": "string"} } } } } }; // Group endpoints by tags const tagGroups: Record = {}; Object.entries(swaggerSpec.paths).forEach(([path, methods]) => { Object.entries(methods as Record).forEach(([method, details]) => { const endpoint: SwaggerEndpoint = { method: method.toUpperCase(), path: path, summary: details.summary || '', description: details.description || '', operationId: details.operationId || '', tags: details.tags || ['default'], parameters: details.parameters || [], requestBody: details.requestBody, responses: details.responses, security: details.security }; const tag = endpoint.tags[0] || 'default'; if (!tagGroups[tag]) { tagGroups[tag] = []; } tagGroups[tag].push(endpoint); }); }); this.swaggerTags = Object.entries(tagGroups).map(([name, endpoints]) => ({ name, endpoints, isOpen: name === 'payments' })); } private initMermaid(): void { if (typeof mermaid !== 'undefined') { mermaid.initialize({ startOnLoad: false, theme: 'default', themeVariables: { primaryColor: '#6366f1', primaryTextColor: '#1e293b', primaryBorderColor: '#818cf8', lineColor: '#64748b', secondaryColor: '#f1f5f9', tertiaryColor: '#ffffff', background: '#ffffff', mainBkg: '#f8fafc', textColor: '#1e293b', }, }); } } renderMermaid(): void { setTimeout(() => { if (typeof mermaid !== 'undefined') { const element = document.querySelector('.mermaid'); if (element) { element.innerHTML = this.mermaidDiagram; mermaid.run({ nodes: [element] }); } } }, 100); } setActiveSection(sectionId: string): void { this.activeSection.set(sectionId); if (sectionId === 'flow') { this.renderMermaid(); } } setActiveApiTab(tabId: string): void { this.activeApiTab.set(tabId); } toggleStep(step: Step): void { step.isOpen = !step.isOpen; } toggleSwaggerTag(tag: SwaggerTag): void { tag.isOpen = !tag.isOpen; } selectSwaggerEndpoint(endpoint: SwaggerEndpoint): void { this.selectedSwaggerEndpoint.set(endpoint); } closeSwaggerDetail(): void { this.selectedSwaggerEndpoint.set(null); } async copyCode(code: string, codeId: string): Promise { try { await navigator.clipboard.writeText(code); this.copiedCode.set(codeId); setTimeout(() => this.copiedCode.set(null), 2000); } catch (err) { console.error('Failed to copy:', err); } } getMethodClass(method: string): string { const classes: Record = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete', PATCH: 'method-patch', }; return classes[method] || 'method-get'; } getStepColorClass(color: string): string { return `step-${color}`; } getNoteClass(type: string): string { return `note-${type}`; } getFilteredSwaggerTags(): SwaggerTag[] { const search = this.swaggerSearch().toLowerCase(); if (!search) return this.swaggerTags; return this.swaggerTags.map(tag => ({ ...tag, endpoints: tag.endpoints.filter( e => e.path.toLowerCase().includes(search) || e.summary.toLowerCase().includes(search) || e.method.toLowerCase().includes(search) ) })).filter(tag => tag.endpoints.length > 0); } onSwaggerSearch(event: Event): void { const value = (event.target as HTMLInputElement).value; this.swaggerSearch.set(value); } formatJson(obj: any): string { return JSON.stringify(obj, null, 2); } getParametersByLocation(params: any[], location: string): any[] { return params?.filter(p => p.in === location) || []; } }