/** * @file Deep Link Handler Service * @author Matthew Raymer * * This service handles the processing and routing of deep links in the TimeSafari app. * It provides a type-safe interface between the raw deep links and the application router. * * Architecture: * 1. DeepLinkHandler class encapsulates all deep link processing logic * 2. Uses Zod schemas from interfaces/deepLinks for parameter validation * 3. Provides consistent error handling and logging * 4. Maps validated parameters to Vue router calls * * Error Handling Strategy: * - All errors are wrapped in DeepLinkError interface * - Errors include error codes for systematic handling * - Detailed error information is logged for debugging * - Errors are propagated to the global error handler * * Validation Strategy: * - URL structure validation * - Route-specific parameter validation using Zod schemas * - Query parameter validation and sanitization * - Type-safe parameter passing to router * * Deep Link Format: * timesafari://[/][?queryParam1=value1&queryParam2=value2] * * Supported Routes: * - claim: View claim * - claim-add-raw: Add raw claim * - claim-cert: View claim certificate * - confirm-gift * - contact-import: Import contacts * - did: View DID * - invite-one-accept: Accept invitation * - onboard-meeting-members * - project: View project details * - user-profile: View user profile * * @example * const handler = new DeepLinkHandler(router); * await handler.handleDeepLink("timesafari://claim/123?view=details"); */ import { Router } from "vue-router"; import { z } from "zod"; import { deepLinkSchemas, baseUrlSchema, routeSchema, DeepLinkRoute, } from "../interfaces/deepLinks"; import { logConsoleAndDb } from "../db/databaseUtil"; import type { DeepLinkError } from "../interfaces/deepLinks"; // Helper function to extract the first key from a Zod object schema function getFirstKeyFromZodObject(schema: z.ZodObject): string | undefined { const shape = schema.shape; const keys = Object.keys(shape); return keys.length > 0 ? keys[0] : undefined; } /** * Maps deep link routes to their corresponding Vue router names and optional parameter keys. * * It's an object where keys are the deep link routes and values are objects with 'name' and 'paramKey'. * * The paramKey is used to extract the parameter from the route path, * because "router.replace" expects the right parameter name for the route. */ export const ROUTE_MAP: Record = Object.entries(deepLinkSchemas).reduce((acc, [routeName, schema]) => { const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject); acc[routeName] = { name: routeName, paramKey }; return acc; }, {} as Record); /** * Handles processing and routing of deep links in the application. * Provides validation, error handling, and routing for deep link URLs. */ export class DeepLinkHandler { private router: Router; /** * Creates a new DeepLinkHandler instance. * @param router - Vue Router instance for navigation */ constructor(router: Router) { this.router = router; } /** * Parses deep link URL into path, params and query components. * Validates URL structure using Zod schemas. * * @param url - The deep link URL to parse (format: scheme://path[?query]) * @throws {DeepLinkError} If URL format is invalid * @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string}) */ private parseDeepLink(url: string) { const parts = url.split("://"); if (parts.length !== 2) { throw { code: "INVALID_URL", message: "Invalid URL format" }; } // Validate base URL structure baseUrlSchema.parse({ scheme: parts[0], path: parts[1], queryParams: {}, // Will be populated below }); const [path, queryString] = parts[1].split("?"); const [routePath, ...pathParams] = path.split("/"); // Validate route exists before proceeding if (!ROUTE_MAP[routePath]) { throw { code: "INVALID_ROUTE", message: `Invalid route path: ${routePath}`, details: { routePath }, }; } const query: Record = {}; if (queryString) { new URLSearchParams(queryString).forEach((value, key) => { query[key] = value; }); } const params: Record = {}; if (pathParams) { // Now we know routePath exists in ROUTE_MAP const routeConfig = ROUTE_MAP[routePath]; params[routeConfig.paramKey ?? "id"] = pathParams.join("/"); } // logConsoleAndDb( // `[DeepLink] Debug: Route Path: ${routePath} Path Params: ${JSON.stringify(params)} Query String: ${JSON.stringify(query)}`, // false, // ); return { path: routePath, params, query }; } /** * Routes the deep link to appropriate view with validated parameters. * Validates route and parameters using Zod schemas before routing. * * @param path - The route path from the deep link * @param params - URL parameters * @param query - Query string parameters * @throws {DeepLinkError} If validation fails or route is invalid */ private async validateAndRoute( path: string, params: Record, query: Record, ): Promise { // First try to validate the route path let routeName: string; try { // Validate route exists const validRoute = routeSchema.parse(path) as DeepLinkRoute; routeName = ROUTE_MAP[validRoute].name; } catch (error) { // Log the invalid route attempt logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true); // Redirect to error page with information about the invalid link await this.router.replace({ name: "deep-link-error", params, query: { originalPath: path, errorCode: "INVALID_ROUTE", errorMessage: `The link you followed (${path}) is not supported`, ...query, }, }); // This previously threw an error but we're redirecting so there's no need. return; } // Continue with parameter validation as before... const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas]; let validatedParams, validatedQuery; try { validatedParams = await schema.parseAsync(params); validatedQuery = await schema.parseAsync(query); } catch (error) { // For parameter validation errors, provide specific error feedback logConsoleAndDb(`[DeepLink] Invalid parameters for route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with params: ${JSON.stringify(params)} ... and query: ${JSON.stringify(query)}`, true); await this.router.replace({ name: "deep-link-error", params, query: { originalPath: path, errorCode: "INVALID_PARAMETERS", errorMessage: `The link parameters are invalid: ${(error as Error).message}`, ...query, }, }); // This previously threw an error but we're redirecting so there's no need. return; } try { await this.router.replace({ name: routeName, params: validatedParams, query: validatedQuery, }); } catch (error) { logConsoleAndDb(`[DeepLink] Error routing to route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with validated params: ${JSON.stringify(validatedParams)} ... and validated query: ${JSON.stringify(validatedQuery)}`, true); // For parameter validation errors, provide specific error feedback await this.router.replace({ name: "deep-link-error", params: validatedParams, query: { originalPath: path, errorCode: "ROUTING_ERROR", errorMessage: `Error routing to ${routeName}: ${(JSON.stringify(error))}`, ...validatedQuery, }, }); } } /** * Processes incoming deep links and routes them appropriately. * Handles validation, error handling, and routing to the correct view. * * @param url - The deep link URL to process * @throws {DeepLinkError} If URL processing fails */ async handleDeepLink(url: string): Promise { try { const { path, params, query } = this.parseDeepLink(url); // Ensure params is always a Record by converting undefined to empty string const sanitizedParams = Object.fromEntries( Object.entries(params).map(([key, value]) => [key, value ?? ""]), ); await this.validateAndRoute(path, sanitizedParams, query); } catch (error) { const deepLinkError = error as DeepLinkError; logConsoleAndDb( `[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.details}`, true, ); throw { code: deepLinkError.code || "UNKNOWN_ERROR", message: deepLinkError.message, details: deepLinkError.details, }; } } }