diff --git a/src/interfaces/deepLinks.ts b/src/interfaces/deepLinks.ts index a275eecf..f5838dc2 100644 --- a/src/interfaces/deepLinks.ts +++ b/src/interfaces/deepLinks.ts @@ -27,37 +27,8 @@ */ import { z } from "zod"; -// Add a union type of all valid route paths -export const VALID_DEEP_LINK_ROUTES = [ - // note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts - "claim", - "claim-add-raw", - "claim-cert", - "confirm-gift", - "contact-import", - "did", - "invite-one-accept", - "onboard-meeting-setup", - "project", - "user-profile", -] as const; - -// Create a type from the array -export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number]; - -// Update your schema definitions to use this type -export const baseUrlSchema = z.object({ - scheme: z.literal("timesafari"), - path: z.string(), - queryParams: z.record(z.string()).optional(), -}); - -// Use the type to ensure route validation -export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES); - // Parameter validation schemas for each route type export const deepLinkSchemas = { - // note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts claim: z.object({ id: z.string(), }), @@ -72,16 +43,24 @@ export const deepLinkSchemas = { "confirm-gift": z.object({ id: z.string(), }), + "contact-edit": z.object({ + did: z.string(), + }), "contact-import": z.object({ jwt: z.string(), }), + contacts: z.object({ + contactJwt: z.string().optional(), + inviteJwt: z.string().optional(), + }), did: z.object({ did: z.string(), }), "invite-one-accept": z.object({ - jwt: z.string(), + // optional because A) it could be a query param, and B) the page displays an input if things go wrong + jwt: z.string().optional(), }), - "onboard-meeting-setup": z.object({ + "onboard-meeting-members": z.object({ id: z.string(), }), project: z.object({ @@ -92,6 +71,19 @@ export const deepLinkSchemas = { }), }; +// Create a type from the array +export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number]; + +// Update your schema definitions to use this type +export const baseUrlSchema = z.object({ + scheme: z.literal("timesafari"), + path: z.string(), + queryParams: z.record(z.string()).optional(), +}); + +// Add a union type of all valid route paths +export const VALID_DEEP_LINK_ROUTES = Object.keys(deepLinkSchemas) as readonly (keyof typeof deepLinkSchemas)[]; + export type DeepLinkParams = { [K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>; }; @@ -100,3 +92,6 @@ export interface DeepLinkError extends Error { code: string; details?: unknown; } + +// Use the type to ensure route validation +export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES as [string, ...string[]]); diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index 3ac12d1f..42f1c38b 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -72,12 +72,11 @@ const handleDeepLink = async (data: { url: string }) => { await deepLinkHandler.handleDeepLink(data.url); } catch (error) { logger.error("[DeepLink] Error handling deep link: ", error); - handleApiError( - { - message: error instanceof Error ? error.message : safeStringify(error), - } as AxiosError, - "deep-link", - ); + let message: string = error instanceof Error ? error.message : safeStringify(error); + if (data.url) { + message += `\nURL: ${data.url}`; + } + handleApiError({ message } as AxiosError, "deep-link"); } }; diff --git a/src/router/index.ts b/src/router/index.ts index 010972bf..e43e104b 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -73,6 +73,11 @@ const routes: Array = [ name: "contacts", component: () => import("../views/ContactsView.vue"), }, + { + path: "/database-migration", + name: "database-migration", + component: () => import("../views/DatabaseMigration.vue"), + }, { path: "/did/:did?", name: "did", @@ -139,8 +144,9 @@ const routes: Array = [ component: () => import("../views/InviteOneView.vue"), }, { + // optional because A) it could be a query param, and B) the page displays an input if things go wrong path: "/invite-one-accept/:jwt?", - name: "InviteOneAcceptView", + name: "invite-one-accept", component: () => import("../views/InviteOneAcceptView.vue"), }, { @@ -148,11 +154,6 @@ const routes: Array = [ name: "logs", component: () => import("../views/LogView.vue"), }, - { - path: "/database-migration", - name: "database-migration", - component: () => import("../views/DatabaseMigration.vue"), - }, { path: "/new-activity", name: "new-activity", diff --git a/src/services/deepLinks.ts b/src/services/deepLinks.ts index bff3346c..8cd6db88 100644 --- a/src/services/deepLinks.ts +++ b/src/services/deepLinks.ts @@ -44,6 +44,8 @@ */ import { Router } from "vue-router"; +import { z } from "zod"; + import { deepLinkSchemas, baseUrlSchema, @@ -53,6 +55,31 @@ import { 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. @@ -69,30 +96,7 @@ export class DeepLinkHandler { } /** - * Maps deep link routes to their corresponding Vue router names and optional parameter keys. - * - * The paramKey is used to extract the parameter from the route path, - * because "router.replace" expects the right parameter name for the route. - * The default is "id". - */ - private readonly ROUTE_MAP: Record< - string, - { name: string; paramKey?: string } - > = { - // note that similar lists are in src/interfaces/deepLinks.ts - claim: { name: "claim" }, - "claim-add-raw": { name: "claim-add-raw" }, - "claim-cert": { name: "claim-cert" }, - "confirm-gift": { name: "confirm-gift" }, - "contact-import": { name: "contact-import", paramKey: "jwt" }, - did: { name: "did", paramKey: "did" }, - "invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" }, - "onboard-meeting-members": { name: "onboard-meeting-members" }, - project: { name: "project" }, - "user-profile": { name: "user-profile" }, - }; - /** * Parses deep link URL into path, params and query components. * Validates URL structure using Zod schemas. * @@ -115,18 +119,9 @@ export class DeepLinkHandler { const [path, queryString] = parts[1].split("?"); const [routePath, ...pathParams] = path.split("/"); - // logger.info( - // "[DeepLink] Debug:", - // "Route Path:", - // routePath, - // "Path Params:", - // pathParams, - // "Query String:", - // queryString, - // ); // Validate route exists before proceeding - if (!this.ROUTE_MAP[routePath]) { + if (!ROUTE_MAP[routePath]) { throw { code: "INVALID_ROUTE", message: `Invalid route path: ${routePath}`, @@ -144,9 +139,14 @@ export class DeepLinkHandler { const params: Record = {}; if (pathParams) { // Now we know routePath exists in ROUTE_MAP - const routeConfig = this.ROUTE_MAP[routePath]; + 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 }; } @@ -170,7 +170,7 @@ export class DeepLinkHandler { try { // Validate route exists const validRoute = routeSchema.parse(path) as DeepLinkRoute; - routeName = this.ROUTE_MAP[validRoute].name; + routeName = ROUTE_MAP[validRoute].name; } catch (error) { // Log the invalid route attempt logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true); @@ -178,52 +178,65 @@ export class DeepLinkHandler { // 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", - message: `The link you followed (${path}) is not supported`, + errorMessage: `The link you followed (${path}) is not supported`, + ...query, }, }); - throw { - code: "INVALID_ROUTE", - message: `Unsupported route: ${path}`, - }; + // 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 { - const validatedParams = await schema.parseAsync({ - ...params, - ...query, + 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, + 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: "INVALID_PARAMETERS", - message: `The link parameters are invalid: ${(error as Error).message}`, + errorCode: "ROUTING_ERROR", + errorMessage: `Error routing to ${routeName}: ${(JSON.stringify(error))}`, + ...validatedQuery, }, }); - - throw { - code: "INVALID_PARAMETERS", - message: (error as Error).message, - details: error, - params: params, - query: query, - }; } + } /** @@ -235,7 +248,6 @@ export class DeepLinkHandler { */ async handleDeepLink(url: string): Promise { try { - logConsoleAndDb("[DeepLink] Processing URL: " + url, false); const { path, params, query } = this.parseDeepLink(url); // Ensure params is always a Record by converting undefined to empty string const sanitizedParams = Object.fromEntries( @@ -245,7 +257,7 @@ export class DeepLinkHandler { } catch (error) { const deepLinkError = error as DeepLinkError; logConsoleAndDb( - `[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`, + `[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.details}`, true, ); diff --git a/src/views/DeepLinkErrorView.vue b/src/views/DeepLinkErrorView.vue index e65d3b58..f1c47f37 100644 --- a/src/views/DeepLinkErrorView.vue +++ b/src/views/DeepLinkErrorView.vue @@ -31,7 +31,7 @@

Supported Deep Links

  • - timesafari://{{ routeItem }}/:id + timesafari://{{ routeItem }}/:{{ deepLinkSchemaKeys[routeItem] }}
@@ -41,12 +41,19 @@