xtablo-source/apps/api/src/routers/user.ts
Arthur Belleville c56d5718b8
refactor: remove is_temporary flag across the entire codebase
Drop the is_temporary boolean from the DB schema (new migration), types,
API routers/helpers/middleware, and all frontend components and tests.
Access control now relies solely on is_client.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 17:04:11 +02:00

795 lines
24 KiB
TypeScript

import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
import type { Tables } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { getOrganizationBillingState } from "../helpers/billing.js";
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
import { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js";
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
const isMissingRelationError = (code: string | undefined) =>
code === "42P01" || code === "PGRST205";
const getMe = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
const userData = data as Tables<"profiles"> & {
organization_id: number | null;
plan: string | null;
};
if (!userData) {
return c.json({ error: "User not found" }, 404);
}
if (error) {
return c.json({ error: error.message }, 500);
}
let effectivePlan: string | null = userData.plan;
if (userData.organization_id) {
const { plan: organizationPlan } = await getOrganizationPlan(
supabase,
userData.organization_id
);
effectivePlan = organizationPlan;
}
return c.json({ ...userData, plan: effectivePlan });
});
// userRouter.put("/profile", async (c) => {
// const user = c.get("user");
// const supabase = c.get("supabase");
// const body = await c.req.json();
// const { first_name, last_name } = body;
// // Deprecated: name field is deprecated, use first_name and last_name instead
// // Combine first_name and last_name into a single name field
// const name = [first_name, last_name].filter(Boolean).join(" ");
// const updateData =
// first_name && last_name
// ? {
// name,
// first_name,
// last_name,
// }
// : {};
// const { data: profile, error } = await supabase
// .from("profiles")
// .update(updateData)
// .eq("id", user.id)
// .select()
// .single();
// if (error) {
// return c.json({ error: error.message }, 500);
// }
// return c.json({
// message: "Profile updated successfully",
// profile,
// });
// });
const uploadAvatar = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
const body = await c.req.json();
const { content, contentType = "image/jpeg" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
const randomString = Math.random().toString(36).substring(2, 15);
const base64Content = Buffer.from(content, "base64");
const key = `${user.id}/public_avatar_${randomString}.${contentType.split("/")[1]}`;
try {
await s3Client.send(
new PutObjectCommand({
Bucket: "web-assets",
Key: key,
Body: base64Content,
ContentType: contentType,
ContentEncoding: "base64",
})
);
} catch (error) {
console.error("Failed to upload avatar:", error);
return c.json({ error: "Failed to upload avatar" }, 500);
}
const avatarUrl = `https://assets.xtablo.com/${key}`;
const { data, error } = await supabase
.from("profiles")
.update({ avatar_url: avatarUrl })
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Avatar updated successfully",
profile: data,
});
});
const deleteAvatar = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
try {
const listedObjects = await s3Client.send(
new ListObjectsV2Command({
Bucket: "web-assets",
Prefix: `${user.id}/`,
})
);
if (listedObjects.Contents.length === 0) return c.json({ error: "No objects found" }, 404);
await s3Client.send(
new DeleteObjectsCommand({
Bucket: "web-assets",
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
})
);
} catch (error) {
console.error("Failed to delete avatar:", error);
return c.json({ error: "Failed to delete avatar" }, 500);
}
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Avatar deleted successfully",
});
});
const getOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
const organizationId = profile.organization_id;
const { data: organization, error: organizationError } = await supabase
.from("organizations")
.select("id, name, logo_url")
.eq("id", organizationId)
.single();
if (organizationError || !organization) {
return c.json({ error: "Organization not found" }, 404);
}
const { data: members, error: membersError } = await supabase
.from("profiles")
.select("id, email, name, first_name, last_name, avatar_url, created_at, plan")
.eq("organization_id", organizationId)
.order("created_at", { ascending: true });
if (membersError) {
return c.json({ error: "Failed to load organization members" }, 500);
}
const { count: tabloCount, error: tabloCountError } = await supabase
.from("tablos")
.select("id", { count: "exact", head: true })
.eq("organization_id", organizationId)
.is("deleted_at", null);
if (tabloCountError) {
return c.json({ error: "Failed to load organization tablos" }, 500);
}
const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId);
if (planError) {
return c.json({ error: "Failed to resolve organization plan" }, 500);
}
const membersWithEffectivePlan = (members || []).map((member) => ({
...member,
plan,
}));
const { data: billingState, error: billingError } = await getOrganizationBillingState(
supabase,
organizationId
);
if (billingError || !billingState) {
return c.json({ error: "Failed to resolve organization billing state" }, 500);
}
let invitesSent: Array<{
id: number;
invited_email: string;
invited_user_id: string | null;
created_at: string;
invited_member: {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
} | null;
}> = [];
const { data: invitesData, error: invitesError } = await supabase
.from("organization_invites")
.select(
`
id,
invited_email,
invited_user_id,
created_at,
invited_member:profiles!organization_invites_invited_user_id_fkey(
id,
email,
name,
first_name,
last_name,
avatar_url
)
`
)
.eq("organization_id", organizationId)
.eq("invited_by", user.id)
.order("created_at", { ascending: false });
if (invitesError && !isMissingRelationError(invitesError.code)) {
return c.json({ error: "Failed to load organization invites" }, 500);
}
if (!invitesError && invitesData) {
invitesSent = invitesData.map((invite) => {
const invitedMemberRaw = invite.invited_member as
| {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
}
| {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
}[]
| null;
return {
id: invite.id as number,
invited_email: (invite.invited_email as string) ?? "",
invited_user_id: (invite.invited_user_id as string | null) ?? null,
created_at: (invite.created_at as string) ?? new Date().toISOString(),
invited_member: Array.isArray(invitedMemberRaw)
? (invitedMemberRaw[0] ?? null)
: invitedMemberRaw,
};
});
}
return c.json({
organization: {
id: organization.id,
name: organization.name,
logo_url: organization.logo_url ?? null,
plan,
member_count: members?.length || 0,
tablo_count: tabloCount || 0,
},
members: membersWithEffectivePlan,
trial_starts_at: billingState.trial_starts_at,
trial_ends_at: billingState.trial_ends_at,
is_trial_expired: billingState.is_trial_expired,
required_plan: billingState.required_plan,
required_team_quantity: billingState.required_team_quantity,
active_subscription_plan: billingState.active_subscription_plan,
active_subscription_quantity: billingState.active_subscription_quantity,
is_billing_owner: billingState.owner_user_id === user.id,
invites_sent: invitesSent,
});
});
const updateOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
const body = await c.req.json();
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
const organizationId = profile.organization_id;
const updateData: Record<string, unknown> = {};
// Handle name update
if (body?.name !== undefined) {
const rawName = typeof body.name === "string" ? body.name : "";
const name = rawName.trim();
if (name.length < 2 || name.length > 100) {
return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400);
}
updateData.name = name;
}
// Handle logo upload
if (body?.logo !== undefined) {
if (body.logo === null) {
// Remove logo
await deleteOrgIcons(s3Client, organizationId);
updateData.logo_url = null;
} else if (body.logo?.content && body.logo?.contentType) {
const { content, contentType } = body.logo;
// Validate content type
const allowedTypes = ["image/png", "image/jpeg", "image/webp"];
if (!allowedTypes.includes(contentType)) {
return c.json({ error: "Logo must be PNG, JPEG, or WebP" }, 400);
}
const imageBuffer = Buffer.from(content, "base64");
try {
const basePath = await uploadOrgIcons(s3Client, organizationId, imageBuffer);
updateData.logo_url = basePath;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to process logo";
return c.json({ error: message }, 400);
}
}
}
if (Object.keys(updateData).length > 0) {
const { error: updateError } = await supabase
.from("organizations")
.update(updateData)
.eq("id", organizationId);
if (updateError) {
return c.json({ error: updateError.message }, 500);
}
}
return c.json({ message: "Organization updated successfully" });
});
const inviteToOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const transporter = c.get("transporter");
const body = await c.req.json();
const rawEmail = typeof body?.email === "string" ? body.email : "";
const recipientEmail = rawEmail.trim().toLowerCase();
if (!recipientEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) {
return c.json({ error: "A valid email is required" }, 400);
}
const { data: senderProfile, error: senderError } = await supabase
.from("profiles")
.select("organization_id, email")
.eq("id", user.id)
.single();
if (senderError || !senderProfile?.organization_id || !senderProfile?.email) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
if (senderProfile.email.toLowerCase() === recipientEmail) {
return c.json({ error: "You cannot invite yourself" }, 400);
}
const organizationId = senderProfile.organization_id;
const { data: billingState, error: billingError } = await getOrganizationBillingState(
supabase,
organizationId
);
if (billingError || !billingState) {
return c.json({ error: "Failed to resolve organization billing state" }, 500);
}
const { data: existingUser, error: existingUserError } = await supabase
.from("profiles")
.select("id, organization_id")
.eq("email", recipientEmail)
.maybeSingle();
if (existingUserError) {
return c.json({ error: existingUserError.message }, 500);
}
if (existingUser) {
if (existingUser.organization_id === organizationId) {
return c.json({ message: "User is already in your organization" });
}
return c.json({ error: "This email already belongs to another organization" }, 409);
}
const TEAM_PLAN_MEMBER_LIMIT = 3;
if (
billingState.active_subscription_plan === "team" &&
billingState.member_count >= TEAM_PLAN_MEMBER_LIMIT
) {
return c.json(
{
error: `The Teams plan is limited to ${TEAM_PLAN_MEMBER_LIMIT} members per organization. Upgrade to the Founder plan to add unlimited members.`,
},
403
);
}
if (billingState.is_trial_expired && billingState.active_subscription_plan !== "annual") {
const requiredSeatsForInvite = billingState.member_count + 1;
if (billingState.active_subscription_plan === "team") {
if (billingState.active_subscription_quantity < requiredSeatsForInvite) {
return c.json(
{
error:
"Your Teams subscription does not have enough seats for this invite. Please increase seats in billing and try again.",
},
403
);
}
} else {
const noSubscriptionMessage =
billingState.required_plan === "solo"
? "Your trial has ended. Solo allows 1 member only. Upgrade to Teams to invite more members."
: "Your trial has ended. An active Teams subscription is required to invite members. Please subscribe or increase seats in billing.";
return c.json(
{
error: noSubscriptionMessage,
},
403
);
}
}
const invitedUser = await createInvitedUser(
supabase,
transporter,
recipientEmail,
senderProfile.email,
);
if (!invitedUser.success || !invitedUser.userId) {
return c.json({ error: invitedUser.error || "Failed to create invited user" }, 500);
}
const { data: createdProfile, error: createdProfileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", invitedUser.userId)
.single();
if (createdProfileError) {
return c.json({ error: "Invited user was created, but profile update failed" }, 500);
}
const oldOrganizationId = createdProfile.organization_id;
const { error: assignOrganizationError } = await supabase
.from("profiles")
.update({ organization_id: organizationId })
.eq("id", invitedUser.userId);
if (assignOrganizationError) {
return c.json({ error: "Failed to assign invited user to your organization" }, 500);
}
const { data: organizationTablos, error: organizationTablosError } = await supabase
.from("tablos")
.select("id")
.eq("organization_id", organizationId)
.is("deleted_at", null);
if (organizationTablosError) {
return c.json({ error: "Failed to sync organization access" }, 500);
}
if ((organizationTablos || []).length > 0) {
const accessRows = (organizationTablos || []).map((tablo) => ({
tablo_id: tablo.id,
user_id: invitedUser.userId!,
granted_by: user.id,
is_active: true,
is_admin: true,
}));
const { error: accessError } = await supabase.from("tablo_access").upsert(accessRows, {
onConflict: "tablo_id,user_id",
});
if (accessError) {
return c.json({ error: "Failed to sync invited user tablo permissions" }, 500);
}
}
if (oldOrganizationId && oldOrganizationId !== organizationId) {
const { count: oldOrgMembersCount } = await supabase
.from("profiles")
.select("id", { count: "exact", head: true })
.eq("organization_id", oldOrganizationId);
if ((oldOrgMembersCount || 0) === 0) {
await supabase.from("organizations").delete().eq("id", oldOrganizationId);
}
}
const { error: inviteHistoryError } = await supabase.from("organization_invites").insert({
organization_id: organizationId,
invited_by: user.id,
invited_email: recipientEmail,
invited_user_id: invitedUser.userId,
});
if (inviteHistoryError && !isMissingRelationError(inviteHistoryError.code)) {
console.error("Failed to store organization invite history:", inviteHistoryError);
}
return c.json({
message: "Invitation sent successfully",
limits: {
plan: billingState.active_subscription_plan ?? billingState.required_plan,
max_tablos_for_solo: MAX_TABLO_LIMIT,
required_team_quantity_for_next_invite: billingState.member_count + 1,
},
});
});
const removeOrganizationMember = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const memberId = c.req.param("memberId");
if (!memberId) {
return c.json({ error: "Member id is required" }, 400);
}
const { data: actorProfile, error: actorProfileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (actorProfileError || !actorProfile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
const organizationId = actorProfile.organization_id;
const { data: billingState, error: billingError } = await getOrganizationBillingState(
supabase,
organizationId
);
if (billingError || !billingState) {
return c.json({ error: "Failed to resolve organization billing state" }, 500);
}
if (billingState.owner_user_id !== user.id) {
return c.json({ error: "Only the organization creator can remove members" }, 403);
}
if (memberId === billingState.owner_user_id) {
return c.json({ error: "The organization creator cannot be removed" }, 400);
}
const { data: memberProfile, error: memberProfileError } = await supabase
.from("profiles")
.select("id, email, name, first_name, last_name, organization_id")
.eq("id", memberId)
.maybeSingle();
if (memberProfileError) {
return c.json({ error: memberProfileError.message }, 500);
}
if (!memberProfile || memberProfile.organization_id !== organizationId) {
return c.json({ error: "Member not found in your organization" }, 404);
}
const baseName =
[memberProfile.first_name, memberProfile.last_name].filter(Boolean).join(" ").trim() ||
memberProfile.name?.trim() ||
memberProfile.email?.split("@")[0]?.trim() ||
"Personal";
const { data: newOrganization, error: newOrganizationError } = await supabase
.from("organizations")
.insert({ name: `${baseName}'s Workspace` })
.select("id")
.single();
if (newOrganizationError || !newOrganization) {
return c.json({ error: "Failed to create a workspace for this member" }, 500);
}
const { error: assignError } = await supabase
.from("profiles")
.update({ organization_id: newOrganization.id })
.eq("id", memberId);
if (assignError) {
return c.json({ error: "Failed to remove member from organization" }, 500);
}
const { error: transferOwnershipError } = await supabase
.from("tablos")
.update({ owner_id: user.id })
.eq("organization_id", organizationId)
.eq("owner_id", memberId);
if (transferOwnershipError) {
return c.json({ error: "Failed to transfer ownership for member tablos" }, 500);
}
const { data: organizationTablos, error: tablosError } = await supabase
.from("tablos")
.select("id")
.eq("organization_id", organizationId);
if (tablosError) {
return c.json({ error: "Failed to synchronize organization access" }, 500);
}
const tabloIds = (organizationTablos || []).map((tablo) => tablo.id);
if (tabloIds.length > 0) {
const { error: removeAccessError } = await supabase
.from("tablo_access")
.delete()
.eq("user_id", memberId)
.in("tablo_id", tabloIds);
if (removeAccessError) {
return c.json({ error: "Failed to revoke member tablo permissions" }, 500);
}
}
const { error: inviteCleanupError } = await supabase
.from("organization_invites")
.delete()
.eq("organization_id", organizationId)
.eq("invited_user_id", memberId);
if (inviteCleanupError && !isMissingRelationError(inviteCleanupError.code)) {
console.error("Failed to clean organization invite history:", inviteCleanupError);
}
return c.json({ message: "Member removed successfully" });
});
const deleteMe = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { data: rawProfile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !rawProfile) {
return c.json({ error: "User not found" }, 404);
}
const profile = rawProfile as typeof rawProfile & { organization_id: number | null };
const deletedAt = new Date().toISOString();
let orgWasSoftDeleted = false;
if (profile.organization_id) {
const { count, error: countError } = await supabase
.from("profiles")
.select("id", { count: "exact", head: true })
.eq("organization_id", profile.organization_id);
if (countError) {
console.warn("Failed to count org members during account deletion, skipping org soft-delete:", countError.message);
} else if ((count ?? 0) === 1) {
const { error: orgDeleteError } = await (supabase.from("organizations") as any)
.update({ deleted_at: deletedAt })
.eq("id", profile.organization_id);
if (orgDeleteError) {
return c.json({ error: "Failed to delete account" }, 500);
}
orgWasSoftDeleted = true;
}
}
const { error: profileDeleteError } = await (supabase.from("profiles") as any)
.update({ deleted_at: deletedAt })
.eq("id", user.id);
if (profileDeleteError) {
if (orgWasSoftDeleted) {
const { error: rollbackErr } = await (supabase.from("organizations") as any)
.update({ deleted_at: null })
.eq("id", profile.organization_id);
if (rollbackErr) console.error("Failed to roll back org soft-delete:", rollbackErr.message);
}
return c.json({ error: "Failed to delete account" }, 500);
}
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id);
if (authDeleteError) {
const { error: profileRollbackErr } = await (supabase.from("profiles") as any)
.update({ deleted_at: null })
.eq("id", user.id);
if (profileRollbackErr) console.error("Failed to roll back profile soft-delete:", profileRollbackErr.message);
if (orgWasSoftDeleted) {
const { error: orgRollbackErr } = await (supabase.from("organizations") as any)
.update({ deleted_at: null })
.eq("id", profile.organization_id);
if (orgRollbackErr) console.error("Failed to roll back org soft-delete:", orgRollbackErr.message);
}
return c.json({ error: "Failed to delete account" }, 500);
}
return c.json({ message: "Account deleted successfully" });
});
export const getUserRouter = () => {
const userRouter = new Hono();
userRouter.get("/me", ...getMe);
userRouter.delete("/me", ...deleteMe);
userRouter.post("/profile/avatar", ...uploadAvatar);
userRouter.delete("/profile/avatar", ...deleteAvatar);
userRouter.get("/organization", ...getOrganization);
userRouter.patch("/organization", ...updateOrganization);
userRouter.post("/organization/invite", ...inviteToOrganization);
userRouter.delete("/organization/members/:memberId", ...removeOrganizationMember);
return userRouter;
};