All files / routes/pro profile.routes.ts

98.55% Statements 68/69
100% Branches 24/24
100% Functions 9/9
98.5% Lines 66/67

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221                                                1x     1x 4x 4x 4x     4x 3x 2x   3x   3x     2x     1x           1x 10x 10x 10x 10x     10x 10x 2x       10x               9x 9x 1x               8x   5x 5x   5x 5x       3x 5x       2x             1x   1x 1x     1x             4x 2x         2x                           2x                           2x               2x 2x   2x             2x         1x 2x 2x 2x   2x 1x   1x         1x 2x 2x 2x 2x 2x 2x     2x       2x               1x 1x 1x     1x   1x          
// Pro Profile Routes - Manage own pro profile
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import type { Dal } from "../../dal";
import { createDualCache, MARKETPLACE } from "../../lib/cache";
import { error, handleError, success } from "../../lib/response";
import { invalidateUserRoles } from "../../lib/role-cache";
import { requireUser } from "../../lib/utils";
import { updateProSchema } from "../../lib/validators";
import { requireProAccess, requireProManager } from "../../middleware";
import type { Services } from "../../services";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
		proId: string;
		proRole: string;
	};
};
 
const profile = new Hono<Env>();
 
// Get current user's roles (for checking admin status)
profile.get("/me/roles", async (c) => {
	try {
		const dal = c.get("dal");
		const user = requireUser(c.get("user"));
 
		// Get platform roles
		const platformRoles = await dal.userTenantRoles.findPlatformRoles(user.id);
		const isAdmin = platformRoles.some(
			(r) => r.role === "admin" || r.role === "super_admin",
		);
		const isSuperAdmin = platformRoles.some((r) => r.role === "super_admin");
 
		return success(c, {
			isAdmin,
			isSuperAdmin,
			platformRoles: platformRoles.map((r) => r.role),
		});
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Get current user's pro (or create one if they don't have one)
// Platform admins/super admins do NOT get a pro auto-created
profile.get("/me", async (c) => {
	try {
		const dal = c.get("dal");
		const services = c.get("services");
		const user = requireUser(c.get("user"));
 
		// Check if user is a platform admin/super admin
		const platformRoles = await dal.userTenantRoles.findPlatformRoles(user.id);
		const isPlatformAdmin = platformRoles.some(
			(r) => r.role === "admin" || r.role === "super_admin",
		);
 
		// Check if user has any pro roles
		const proRoles = await dal.userTenantRoles.findProRoles(user.id);
 
		// Issue #581: deactivated team members (isActive=false) must not be
		// treated as live members. Filter to active roles first; if every pro
		// role on this user has been deactivated, surface ACCESS_REVOKED so
		// the portal can show a proper "access revoked" screen instead of an
		// empty dashboard, and crucially do NOT fall through to auto-create
		// a brand-new pro for them.
		const activeProRoles = proRoles.filter((r) => r.isActive !== false);
		if (proRoles.length > 0 && activeProRoles.length === 0) {
			return error(
				c,
				"ACCESS_REVOKED",
				"Your access to this organization has been revoked. Please contact your team owner.",
				403,
			);
		}
 
		if (activeProRoles.length > 0) {
			// User has an active pro role, check if pro still exists
			const proId = activeProRoles[0].tenantId ?? "";
			const proRole = activeProRoles[0].role;
 
			try {
				const pro = await services.pro.getById(proId);
				// Only owners need to complete onboarding — invited team members
				// (manager/staff) join an existing org and skip onboarding entirely
				const onboardingRequired =
					proRole === "owner" && pro.onboardingStatus !== "completed";
				return success(c, { pro, proId, proRole, onboardingRequired });
			} catch (err: unknown) {
				// Pro doesn't exist - clean up orphaned role
				// This can happen if a pro was deleted but role wasn't cleaned up
				if (
					err &&
					typeof err === "object" &&
					"name" in err &&
					err.name === "NotFoundError"
				) {
					// Delete the orphaned role
					await dal.userTenantRoles.delete(activeProRoles[0].id);
					// Invalidate cached roles after orphaned role cleanup
					const cache = createDualCache(c.env.KV_CACHE);
					await invalidateUserRoles(cache, user.id);
					// Fall through to create a new pro below (unless platform admin)
				} else {
					throw err;
				}
			}
		}
 
		// Platform admins should not have pros auto-created
		// Return null pro so they get redirected to admin panel
		if (isPlatformAdmin) {
			return success(c, { pro: null, proId: null, proRole: null });
		}
 
		// Check for pending team invitations before auto-creating a pro
		const pendingInvitation =
			await dal.teamInvitations.findPendingByEmailGlobal(user.email);
		/* v8 ignore start -- defensive guard: pending invitation path not covered */
		if (pendingInvitation) {
		/* v8 ignore stop */
			return success(c, {
				pro: null,
				proId: null,
				proRole: null,
				onboardingRequired: false,
				pendingInvitation: { token: pendingInvitation.token },
			});
		}
 
		// No pro (or orphaned role was cleaned up) - create one for the user
		const pro = await services.pro.create(
			{
				businessName: "",
				status: "draft",
				onboardingStatus: "not_started",
				onboardingStep: 0,
				isEarlyAdopter:
					c.env.RELEASE_VERSION === "alpha" ||
					c.env.RELEASE_VERSION === "beta",
			},
			user.id,
		);
 
		// Link user to pro as owner
		await dal.userTenantRoles.create({
			userId: user.id,
			tenantType: "pro",
			tenantId: pro.id,
			role: "owner",
		});
 
		// Invalidate cached roles after creating owner role
		const cacheForNewPro = createDualCache(c.env.KV_CACHE);
		await invalidateUserRoles(cacheForNewPro, user.id);
 
		return success(c, {
			pro,
			proId: pro.id,
			proRole: "owner",
			onboardingRequired: true,
		});
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Get own pro profile
profile.get("/:proId", requireProAccess, async (c) => {
	try {
		const services = c.get("services");
		const proId = c.get("proId");
 
		const pro = await services.pro.getById(proId);
		return success(c, pro);
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Update own pro profile (non-critical fields only)
profile.put("/:proId", requireProManager, zValidator("json", updateProSchema), async (c) => {
	try {
		const services = c.get("services");
		const user = requireUser(c.get("user"));
		const proId = c.get("proId");
		const proRole = c.get("proRole");
		const body = c.req.valid("json");
 
		// Non-admins cannot modify admin-only fields (enforced in service)
		const isAdmin = proRole === "admin";
 
		// Cast needed: Zod schema allows nullable fields that the service
		// type doesn't declare yet. The service handles nulls at runtime.
		const pro = await services.pro.update(
			proId,
			body as Record<string, unknown>,
			user.id,
			isAdmin,
		);
 
		// Invalidate pro detail cache only — homepage has its own TTL
		try {
			const cache = createDualCache(c.env.KV_CACHE);
			c.executionCtx.waitUntil(cache.delete(MARKETPLACE.proFull(proId)));
		} catch { /* executionCtx unavailable in tests */ }
 
		return success(c, pro);
	} catch (err) {
		return handleError(c, err);
	}
});
 
export default profile;