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>
795 lines
24 KiB
TypeScript
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;
|
|
};
|