You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
271 lines
8.9 KiB
271 lines
8.9 KiB
/**
|
|
* @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://<route>[/<param>][?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<any>): 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<string, { name: string; paramKey?: string }> =
|
|
Object.entries(deepLinkSchemas).reduce((acc, [routeName, schema]) => {
|
|
const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject<any>);
|
|
acc[routeName] = {
|
|
name: routeName,
|
|
paramKey
|
|
};
|
|
return acc;
|
|
}, {} as Record<string, { name: string; paramKey?: string }>);
|
|
|
|
/**
|
|
* 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<string, string> = {};
|
|
if (queryString) {
|
|
new URLSearchParams(queryString).forEach((value, key) => {
|
|
query[key] = value;
|
|
});
|
|
}
|
|
|
|
const params: Record<string, string> = {};
|
|
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<string, string>,
|
|
query: Record<string, string>,
|
|
): Promise<void> {
|
|
// 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<void> {
|
|
try {
|
|
const { path, params, query } = this.parseDeepLink(url);
|
|
// Ensure params is always a Record<string,string> 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,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|