Merge pull request #59 from artslidd/develop
New feature: organizations
This commit is contained in:
commit
e902c3a7c2
22 changed files with 1776 additions and 288 deletions
|
|
@ -4,13 +4,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { MAX_TABLO_LIMIT, verifyTabloLimitForUser } from "../../helpers/helpers.js";
|
||||
|
||||
const createSupabaseMock = ({
|
||||
profileData = { plan: "free" },
|
||||
profileData = { organization_id: 1 },
|
||||
organizationPlans = [{ plan: "free" }],
|
||||
profileError = null,
|
||||
organizationPlanError = null,
|
||||
tabloCount = 0,
|
||||
tabloError = null,
|
||||
} = {}) => {
|
||||
const profileSingle = vi.fn(async () => ({ data: profileData, error: profileError }));
|
||||
const profileEq = vi.fn(() => ({ single: profileSingle }));
|
||||
const profileEq = vi.fn((column: string) => {
|
||||
if (column === "id") {
|
||||
return { single: profileSingle };
|
||||
}
|
||||
return Promise.resolve({ data: organizationPlans, error: organizationPlanError });
|
||||
});
|
||||
const profileSelect = vi.fn(() => ({ eq: profileEq }));
|
||||
|
||||
const tabloEq = vi.fn(async () => ({ count: tabloCount, error: tabloError }));
|
||||
|
|
@ -74,7 +81,8 @@ describe("verifyTabloLimitForUser", () => {
|
|||
|
||||
it("denies free users that reached the tablo limit", async () => {
|
||||
const supabase = createSupabaseMock({
|
||||
profileData: { plan: "free" },
|
||||
profileData: { organization_id: 1 },
|
||||
organizationPlans: [{ plan: "free" }],
|
||||
tabloCount: MAX_TABLO_LIMIT,
|
||||
});
|
||||
const ctx = createContext(supabase, user);
|
||||
|
|
@ -88,7 +96,8 @@ describe("verifyTabloLimitForUser", () => {
|
|||
it("allows free users below the limit to proceed", async () => {
|
||||
const belowLimitCount = Math.max(0, MAX_TABLO_LIMIT - 1);
|
||||
const supabase = createSupabaseMock({
|
||||
profileData: { plan: "free" },
|
||||
profileData: { organization_id: 1 },
|
||||
organizationPlans: [{ plan: "free" }],
|
||||
tabloCount: belowLimitCount,
|
||||
});
|
||||
const ctx = createContext(supabase, user);
|
||||
|
|
@ -101,7 +110,8 @@ describe("verifyTabloLimitForUser", () => {
|
|||
|
||||
it("skips tablo count check for non-free plans", async () => {
|
||||
const supabase = createSupabaseMock({
|
||||
profileData: { plan: "pro" },
|
||||
profileData: { organization_id: 1 },
|
||||
organizationPlans: [{ plan: "standard" }],
|
||||
});
|
||||
const ctx = createContext(supabase, user);
|
||||
|
||||
|
|
|
|||
|
|
@ -252,14 +252,49 @@ describe("Tablo Endpoint", () => {
|
|||
it("should block free plan users who reached the tablo limit", async () => {
|
||||
const { data: profileData } = await supabaseAdmin
|
||||
.from("profiles")
|
||||
.select("plan")
|
||||
.select("plan, organization_id")
|
||||
.eq("id", ownerUser.userId)
|
||||
.single();
|
||||
|
||||
const originalPlan = profileData?.plan ?? "standard";
|
||||
const ownerOrganizationId = profileData?.organization_id;
|
||||
|
||||
if (!ownerOrganizationId) {
|
||||
throw new Error("owner organization_id is required for this test");
|
||||
}
|
||||
|
||||
await supabaseAdmin.from("profiles").update({ plan: "free" }).eq("id", ownerUser.userId);
|
||||
|
||||
const fillerTablos: Array<{
|
||||
owner_id: string;
|
||||
organization_id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
color: string;
|
||||
}> = [];
|
||||
|
||||
// Ensure the organization has at least 10 tablos before creation attempt.
|
||||
const { count: existingCount } = await supabaseAdmin
|
||||
.from("tablos")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("organization_id", ownerOrganizationId)
|
||||
.is("deleted_at", null);
|
||||
|
||||
const missingTablos = Math.max(0, 10 - (existingCount || 0));
|
||||
for (let i = 0; i < missingTablos; i += 1) {
|
||||
fillerTablos.push({
|
||||
owner_id: ownerUser.userId,
|
||||
organization_id: ownerOrganizationId,
|
||||
name: `Limit filler ${i}`,
|
||||
status: "todo",
|
||||
color: "#DDDDEE",
|
||||
});
|
||||
}
|
||||
|
||||
if (fillerTablos.length > 0) {
|
||||
await supabaseAdmin.from("tablos").insert(fillerTablos);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await createTabloRequest(ownerUser, client, {
|
||||
name: "Free Limit Tablo",
|
||||
|
|
@ -289,10 +324,6 @@ describe("Tablo Endpoint", () => {
|
|||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("Tablo updated successfully");
|
||||
|
||||
// Note: The current update logic has a bug where it checks if tablo.name !== updatedTablo.name
|
||||
// after the update, which will always be false. So channel.update is never called.
|
||||
// This should be fixed in the production code to compare with the original name.
|
||||
});
|
||||
|
||||
it("should deny temp user from updating their own tablo (regularUserCheck blocks temporary users)", async () => {
|
||||
|
|
@ -310,7 +341,7 @@ describe("Tablo Endpoint", () => {
|
|||
name: "Should Not Update",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should deny temp user from updating owner's tablo (regularUserCheck blocks temporary users)", async () => {
|
||||
|
|
@ -338,7 +369,7 @@ describe("Tablo Endpoint", () => {
|
|||
name: "Should Not Update",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,56 @@
|
|||
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { EventAndTablo, Tables } from "@xtablo/shared-types";
|
||||
import type { EventAndTablo } from "@xtablo/shared-types";
|
||||
import type { Context, Next } from "hono";
|
||||
import type { Transporter } from "nodemailer";
|
||||
import type { StreamChat } from "stream-chat";
|
||||
import { generatePassword } from "./token.js";
|
||||
|
||||
export const MAX_TABLO_LIMIT = 1;
|
||||
export const MAX_TABLO_LIMIT = 10;
|
||||
export const MAX_TEAM_MEMBER_LIMIT = 3;
|
||||
|
||||
export type NormalizedPlan = "solo" | "team" | "annual";
|
||||
|
||||
const PLAN_WEIGHT: Record<NormalizedPlan, number> = {
|
||||
solo: 1,
|
||||
team: 2,
|
||||
annual: 3,
|
||||
};
|
||||
|
||||
export const normalizePlan = (plan: string | null | undefined): NormalizedPlan => {
|
||||
if (!plan) return "solo";
|
||||
|
||||
if (plan === "annual" || plan === "beta") return "annual";
|
||||
if (plan === "team" || plan === "standard") return "team";
|
||||
return "solo";
|
||||
};
|
||||
|
||||
export const getHighestPlan = (plans: Array<string | null | undefined>): NormalizedPlan => {
|
||||
const normalizedPlans = plans.map((plan) => normalizePlan(plan));
|
||||
if (normalizedPlans.length === 0) {
|
||||
return "solo";
|
||||
}
|
||||
|
||||
return normalizedPlans.sort((a, b) => PLAN_WEIGHT[b] - PLAN_WEIGHT[a])[0]!;
|
||||
};
|
||||
|
||||
export const getOrganizationPlan = async (
|
||||
supabase: SupabaseClient,
|
||||
organizationId: number
|
||||
): Promise<{ plan: NormalizedPlan; error?: string }> => {
|
||||
const { data: plans, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("plan")
|
||||
.eq("organization_id", organizationId);
|
||||
|
||||
if (error) {
|
||||
return { plan: "solo", error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
plan: getHighestPlan((plans || []).map((profile) => profile.plan)),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateICSFromEvents = (
|
||||
events: EventAndTablo[],
|
||||
|
|
@ -184,10 +228,10 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => {
|
|||
const supabase = c.get("supabase");
|
||||
const user = c.get("user");
|
||||
|
||||
// Get user profile to check subscription status
|
||||
// Get user profile to check organization and subscription status
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.select("organization_id")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
|
|
@ -195,13 +239,26 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => {
|
|||
return c.json({ error: "Failed to get user profile" }, 500);
|
||||
}
|
||||
|
||||
const userProfile = profile as Tables<"profiles">;
|
||||
const userProfile = profile as { organization_id: number | null };
|
||||
|
||||
if (userProfile.plan === "free") {
|
||||
if (!userProfile.organization_id) {
|
||||
return c.json({ error: "No organization found for current user" }, 400);
|
||||
}
|
||||
|
||||
const { plan, error: planError } = await getOrganizationPlan(
|
||||
supabase,
|
||||
userProfile.organization_id
|
||||
);
|
||||
if (planError) {
|
||||
return c.json({ error: "Failed to resolve organization plan" }, 500);
|
||||
}
|
||||
|
||||
if (plan === "solo") {
|
||||
const { count, error: countError } = await supabase
|
||||
.from("tablos")
|
||||
.select("id", { count: "exact" })
|
||||
.eq("owner_id", user.id);
|
||||
.eq("organization_id", userProfile.organization_id)
|
||||
.is("deleted_at", null);
|
||||
|
||||
const tabloCount = count as number;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Database, TablesInsert } from "@xtablo/shared-types";
|
||||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import { createInvitedUser } from "../helpers/helpers.js";
|
||||
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
|
||||
import type { EventTypeConfig } from "../helpers/slots.js";
|
||||
import type { MaybeAuthEnv } from "../types/app.types.js";
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
// TODO: Verify that the owner_id is correct
|
||||
const { data: ownerData, error: ownerError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, name, email")
|
||||
.select("id, name, email, organization_id")
|
||||
.eq("short_user_id", data.owner_short_id)
|
||||
.single();
|
||||
|
||||
|
|
@ -83,8 +83,23 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
organization_id: number | null;
|
||||
};
|
||||
const ownerId = ownerDataTyped.id;
|
||||
const ownerOrganizationId = ownerDataTyped.organization_id;
|
||||
|
||||
if (!ownerOrganizationId) {
|
||||
return c.json({ error: "Owner organization not found" }, 500);
|
||||
}
|
||||
|
||||
const { plan: organizationPlan, error: organizationPlanError } = await getOrganizationPlan(
|
||||
supabase,
|
||||
ownerOrganizationId
|
||||
);
|
||||
|
||||
if (organizationPlanError) {
|
||||
return c.json({ error: "Failed to resolve organization plan" }, 500);
|
||||
}
|
||||
|
||||
const bookerUserDataTyped = bookerUser as {
|
||||
id: string;
|
||||
|
|
@ -121,11 +136,11 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
`
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
organization_id,
|
||||
tablo_access!inner(user_id)
|
||||
`
|
||||
)
|
||||
.eq("owner_id", ownerId)
|
||||
.eq("organization_id", ownerOrganizationId)
|
||||
.eq("tablo_access.user_id", bookerUserDataTyped.id)
|
||||
.is("deleted_at", null)
|
||||
.limit(1);
|
||||
|
|
@ -138,6 +153,28 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
let tabloData: { id: string; name: string } | null = null;
|
||||
|
||||
if (!existingTablo.length) {
|
||||
if (organizationPlan === "solo") {
|
||||
const { count: tabloCount, error: tabloCountError } = await supabase
|
||||
.from("tablos")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("organization_id", ownerOrganizationId)
|
||||
.is("deleted_at", null);
|
||||
|
||||
if (tabloCountError) {
|
||||
return c.json({ error: "Failed to validate organization tablo limit" }, 500);
|
||||
}
|
||||
|
||||
if ((tabloCount || 0) >= MAX_TABLO_LIMIT) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Your organization has reached the Solo plan tablo limit. Upgrade your plan to create more tablos.",
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the tablo with the specified owner
|
||||
const { data: insertedTablo, error } = await supabase
|
||||
.from("tablos")
|
||||
|
|
@ -146,6 +183,7 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
color: "bg-blue-500",
|
||||
status: "todo",
|
||||
owner_id: ownerId,
|
||||
organization_id: ownerOrganizationId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
|
@ -182,11 +220,24 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
}
|
||||
|
||||
// Create Stream chat channel with the owner as creator
|
||||
const { data: organizationMembers, error: organizationMembersError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("organization_id", ownerOrganizationId);
|
||||
|
||||
if (organizationMembersError) {
|
||||
return c.json({ error: "Failed to load organization members" }, 500);
|
||||
}
|
||||
|
||||
const channelMembers = Array.from(
|
||||
new Set((organizationMembers || []).map((member) => member.id).concat(bookerUserDataTyped.id))
|
||||
);
|
||||
|
||||
const channel = streamServerClient.channel("messaging", tabloData.id, {
|
||||
// @ts-ignore
|
||||
name: tabloData.name,
|
||||
created_by_id: ownerId,
|
||||
members: [ownerId, bookerUserDataTyped.id],
|
||||
members: channelMembers,
|
||||
});
|
||||
await channel.create();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,85 @@ import { generateToken } from "../helpers/token.js";
|
|||
import { MiddlewareManager } from "../middlewares/middleware.js";
|
||||
import type { AuthEnv } from "../types/app.types.js";
|
||||
|
||||
type PostTablo = Omit<TabloInsert, "owner_id"> & {
|
||||
type PostTablo = Omit<TabloInsert, "owner_id" | "organization_id"> & {
|
||||
events?: EventInsertInTablo[];
|
||||
};
|
||||
|
||||
const factory = createFactory<AuthEnv>();
|
||||
|
||||
const isAlreadyMemberError = (error: unknown): boolean => {
|
||||
if (!error) return false;
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
return (
|
||||
message.includes("already a member") ||
|
||||
message.includes("already member") ||
|
||||
message.includes("member already exists")
|
||||
);
|
||||
};
|
||||
|
||||
const upsertStreamUserFromProfile = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
streamServerClient: AuthEnv["Variables"]["streamServerClient"],
|
||||
userId: string
|
||||
) => {
|
||||
const { data: profile } = await supabase.from("profiles").select("name").eq("id", userId).maybeSingle();
|
||||
|
||||
await streamServerClient.upsertUser({
|
||||
id: userId,
|
||||
name: profile?.name ?? "",
|
||||
language: "fr",
|
||||
});
|
||||
};
|
||||
|
||||
const ensureTabloChannelMember = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
streamServerClient: AuthEnv["Variables"]["streamServerClient"],
|
||||
tabloId: string,
|
||||
userId: string
|
||||
) => {
|
||||
const channel = streamServerClient.channel("messaging", tabloId);
|
||||
|
||||
try {
|
||||
await channel.addMembers([userId]);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isAlreadyMemberError(error)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { data: tablo } = await supabase
|
||||
.from("tablos")
|
||||
.select("name, owner_id")
|
||||
.eq("id", tabloId)
|
||||
.maybeSingle();
|
||||
|
||||
const { data: accessRows } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("user_id")
|
||||
.eq("tablo_id", tabloId)
|
||||
.eq("is_active", true);
|
||||
|
||||
const members = Array.from(new Set((accessRows || []).map((row) => row.user_id).concat(userId)));
|
||||
|
||||
const channelToCreate = streamServerClient.channel("messaging", tabloId, {
|
||||
// @ts-ignore
|
||||
name: tablo?.name ?? "Tablo",
|
||||
created_by_id: tablo?.owner_id ?? userId,
|
||||
members,
|
||||
});
|
||||
|
||||
try {
|
||||
await channelToCreate.create();
|
||||
} catch (error) {
|
||||
if (isAlreadyMemberError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.addMembers([userId]);
|
||||
}
|
||||
};
|
||||
|
||||
const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => {
|
||||
const user = c.get("user");
|
||||
|
|
@ -26,11 +99,22 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
|
|||
|
||||
const typedPayload = data as PostTablo;
|
||||
|
||||
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 { data: insertedTablo, error } = await supabase
|
||||
.from("tablos")
|
||||
.insert({
|
||||
...typedPayload,
|
||||
owner_id: user.id,
|
||||
organization_id: profile.organization_id,
|
||||
events: undefined,
|
||||
})
|
||||
.select()
|
||||
|
|
@ -42,12 +126,25 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
|
|||
|
||||
const tabloData = insertedTablo as Tables<"tablos">;
|
||||
|
||||
const { data: organizationMembers, error: membersError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("organization_id", profile.organization_id);
|
||||
|
||||
if (membersError) {
|
||||
return c.json({ error: "Failed to load organization members" }, 500);
|
||||
}
|
||||
|
||||
const channelMembers = Array.from(
|
||||
new Set((organizationMembers || []).map((member) => member.id).concat(user.id))
|
||||
);
|
||||
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
const channel = streamServerClient.channel("messaging", tabloData.id, {
|
||||
// @ts-ignore
|
||||
name: tabloData.name,
|
||||
created_by_id: user.id,
|
||||
members: [user.id],
|
||||
members: channelMembers,
|
||||
});
|
||||
await channel.create();
|
||||
|
||||
|
|
@ -72,23 +169,32 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
|
|||
|
||||
const { id, ...tablo } = data;
|
||||
|
||||
const { data: access, error: accessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("is_admin")
|
||||
.eq("tablo_id", id)
|
||||
.eq("user_id", user.id)
|
||||
.eq("is_active", true)
|
||||
.single();
|
||||
|
||||
if (accessError || !access || !access.is_admin) {
|
||||
return c.json({ error: "You are not authorized to update this tablo" }, 403);
|
||||
}
|
||||
|
||||
const { data: update, error } = await supabase
|
||||
.from("tablos")
|
||||
.update(tablo)
|
||||
.eq("id", id)
|
||||
// TODO: this condition will need to be modified in the future
|
||||
.eq("owner_id", user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
const updatedTablo = update as Tables<"tablos">;
|
||||
|
||||
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
const updatedTablo = update as Tables<"tablos">;
|
||||
const isUpdatingName = tablo.name !== undefined;
|
||||
|
||||
if (isUpdatingName) {
|
||||
const channel = streamServerClient.channel("messaging", updatedTablo.id);
|
||||
try {
|
||||
|
|
@ -112,12 +218,6 @@ const deleteTablo = factory.createHandlers(async (c) => {
|
|||
|
||||
const { id } = data;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("tablos")
|
||||
.update({ deleted_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.eq("owner_id", user.id);
|
||||
|
||||
// Verify that the user has admin access to this tablo
|
||||
const { data: tabloAccess, error: accessError } = await supabase
|
||||
.from("tablo_access")
|
||||
|
|
@ -131,6 +231,11 @@ const deleteTablo = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "You are not authorized to delete this tablo" }, 403);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("tablos")
|
||||
.update({ deleted_at: new Date().toISOString() })
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
|
@ -242,6 +347,13 @@ const inviteToTablo = (
|
|||
return c.json({ error: tabloAccessError.message }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureTabloChannelMember(supabase, streamServerClient, tabloId, result.userId);
|
||||
} catch (streamError) {
|
||||
console.error("error adding temporary invited user to channel", streamError);
|
||||
return c.json({ error: "Failed to sync chat access for invited user" }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "User created and invite sent successfully",
|
||||
});
|
||||
|
|
@ -288,6 +400,77 @@ ${introEmail ? `<p>${introEmail}</p>` : ""}
|
|||
});
|
||||
});
|
||||
|
||||
const cancelPendingInvite = (
|
||||
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
|
||||
) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const inviteId = Number(c.req.param("inviteId"));
|
||||
|
||||
if (!Number.isInteger(inviteId) || inviteId <= 0) {
|
||||
return c.json({ error: "Invalid invite id" }, 400);
|
||||
}
|
||||
|
||||
const { data: invite, error: inviteError } = await supabase
|
||||
.from("tablo_invites")
|
||||
.select("id, invited_email, is_pending")
|
||||
.eq("id", inviteId)
|
||||
.eq("tablo_id", tabloId)
|
||||
.maybeSingle();
|
||||
|
||||
if (inviteError) {
|
||||
return c.json({ error: inviteError.message }, 500);
|
||||
}
|
||||
|
||||
if (!invite) {
|
||||
return c.json({ error: "Invite not found" }, 404);
|
||||
}
|
||||
|
||||
if (!invite.is_pending) {
|
||||
return c.json({ error: "Invite is no longer pending" }, 400);
|
||||
}
|
||||
|
||||
const { error: cancelError } = await supabase
|
||||
.from("tablo_invites")
|
||||
.update({ is_pending: false })
|
||||
.eq("id", inviteId)
|
||||
.eq("tablo_id", tabloId);
|
||||
|
||||
if (cancelError) {
|
||||
return c.json({ error: cancelError.message }, 500);
|
||||
}
|
||||
|
||||
const { data: invitedProfile } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, is_temporary")
|
||||
.eq("email", invite.invited_email)
|
||||
.maybeSingle();
|
||||
|
||||
// Temporary invitees are pre-added to tablo_access. Revoke this access when invite is cancelled.
|
||||
if (invitedProfile?.id && invitedProfile.is_temporary) {
|
||||
const { error: revokeAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.update({ is_active: false })
|
||||
.eq("tablo_id", tabloId)
|
||||
.eq("user_id", invitedProfile.id);
|
||||
|
||||
if (revokeAccessError) {
|
||||
return c.json({ error: revokeAccessError.message }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = streamServerClient.channel("messaging", tabloId);
|
||||
await channel.removeMembers([invitedProfile.id]);
|
||||
} catch (error) {
|
||||
console.error("error removing cancelled invitee from channel", error);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ message: "Invite cancelled successfully" });
|
||||
});
|
||||
|
||||
const joinTablo = factory.createHandlers(async (c) => {
|
||||
const { token } = await c.req.json();
|
||||
|
||||
|
|
@ -314,6 +497,13 @@ const joinTablo = factory.createHandlers(async (c) => {
|
|||
|
||||
const { id: invite_id, tablo_id, invited_by } = inviteData;
|
||||
|
||||
try {
|
||||
await upsertStreamUserFromProfile(supabase, streamServerClient, joiner.id);
|
||||
} catch (error) {
|
||||
console.error("error upserting joining user to stream", error);
|
||||
return c.json({ error: "Failed to provision chat user" }, 500);
|
||||
}
|
||||
|
||||
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
|
||||
tablo_id,
|
||||
user_id: joiner.id,
|
||||
|
|
@ -327,22 +517,20 @@ const joinTablo = factory.createHandlers(async (c) => {
|
|||
if (tabloAccessError) {
|
||||
console.error("tabloAccessError", tabloAccessError);
|
||||
|
||||
// Check if it's a conflict error (user already has access)
|
||||
if (tabloAccessError.code === "23505") {
|
||||
return c.json({ error: "User already has access to this tablo" }, 409);
|
||||
// If user already has access, continue to sync invite + chat membership.
|
||||
if (tabloAccessError.code !== "23505") {
|
||||
return c.json({ error: tabloAccessError.message }, 500);
|
||||
}
|
||||
|
||||
return c.json({ error: tabloAccessError.message }, 500);
|
||||
}
|
||||
|
||||
// Mark invite as accepted instead of deleting (maintains audit trail)
|
||||
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id);
|
||||
|
||||
try {
|
||||
const channel = streamServerClient.channel("messaging", tablo_id);
|
||||
await channel.addMembers([joiner.id]);
|
||||
await ensureTabloChannelMember(supabase, streamServerClient, tablo_id, joiner.id);
|
||||
} catch (error) {
|
||||
console.error("error adding member to channel", error);
|
||||
return c.json({ error: "Failed to sync chat access for this tablo" }, 500);
|
||||
}
|
||||
|
||||
return c.json({ tablo_id });
|
||||
|
|
@ -369,7 +557,7 @@ const getTabloMembers = factory.createHandlers(async (c) => {
|
|||
|
||||
const { data, error } = await supabase
|
||||
.from("tablo_access")
|
||||
.select("is_admin, profiles(id, name, email)")
|
||||
.select("is_admin, profiles(id, name, email, avatar_url)")
|
||||
.eq("tablo_id", tablo_id)
|
||||
.eq("is_active", true);
|
||||
|
||||
|
|
@ -379,6 +567,7 @@ const getTabloMembers = factory.createHandlers(async (c) => {
|
|||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
}[];
|
||||
|
||||
|
|
@ -391,6 +580,7 @@ const getTabloMembers = factory.createHandlers(async (c) => {
|
|||
...member.profiles,
|
||||
is_admin: member.is_admin,
|
||||
email: member.profiles.email,
|
||||
avatar_url: member.profiles.avatar_url,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
|
@ -523,6 +713,7 @@ export const getTabloRouter = (config: AppConfig) => {
|
|||
tabloRouter.patch("/update", ...updateTablo(middlewareManager));
|
||||
tabloRouter.delete("/delete", ...deleteTablo);
|
||||
tabloRouter.post("/invite/:tabloId", ...inviteToTablo(config, middlewareManager));
|
||||
tabloRouter.delete("/invite/:tabloId/:inviteId", ...cancelPendingInvite(middlewareManager));
|
||||
tabloRouter.post("/join", ...joinTablo);
|
||||
tabloRouter.get("/members/:tablo_id", ...getTabloMembers);
|
||||
tabloRouter.post("/leave", ...leaveTablo);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@a
|
|||
import type { Tables } from "@xtablo/shared-types";
|
||||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import {
|
||||
createInvitedUser,
|
||||
getOrganizationPlan,
|
||||
MAX_TABLO_LIMIT,
|
||||
MAX_TEAM_MEMBER_LIMIT,
|
||||
} from "../helpers/helpers.js";
|
||||
import type { AuthEnv } from "../types/app.types.js";
|
||||
|
||||
const factory = createFactory<AuthEnv>();
|
||||
|
|
@ -260,6 +266,280 @@ const deleteAvatar = factory.createHandlers(async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
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")
|
||||
.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, is_temporary, 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);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
organization: {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
plan,
|
||||
member_count: members?.length || 0,
|
||||
tablo_count: tabloCount || 0,
|
||||
},
|
||||
members: members || [],
|
||||
});
|
||||
});
|
||||
|
||||
const updateOrganization = factory.createHandlers(async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const body = await c.req.json();
|
||||
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);
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id, is_temporary")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile?.organization_id) {
|
||||
return c.json({ error: "Failed to resolve your organization" }, 500);
|
||||
}
|
||||
|
||||
if (profile.is_temporary) {
|
||||
return c.json({ error: "Temporary users cannot update organization settings" }, 403);
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("organizations")
|
||||
.update({ name })
|
||||
.eq("id", profile.organization_id);
|
||||
|
||||
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 streamServerClient = c.get("streamServerClient");
|
||||
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, is_temporary")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (senderError || !senderProfile?.organization_id || !senderProfile?.email) {
|
||||
return c.json({ error: "Failed to resolve your organization" }, 500);
|
||||
}
|
||||
|
||||
if (senderProfile.is_temporary) {
|
||||
return c.json({ error: "Temporary users cannot invite organization members" }, 403);
|
||||
}
|
||||
|
||||
if (senderProfile.email.toLowerCase() === recipientEmail) {
|
||||
return c.json({ error: "You cannot invite yourself" }, 400);
|
||||
}
|
||||
|
||||
const organizationId = senderProfile.organization_id;
|
||||
const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId);
|
||||
|
||||
if (planError) {
|
||||
return c.json({ error: "Failed to resolve organization plan" }, 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 { count: membersCount, error: membersCountError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("organization_id", organizationId);
|
||||
|
||||
if (membersCountError) {
|
||||
return c.json({ error: "Failed to check organization size" }, 500);
|
||||
}
|
||||
|
||||
if (plan === "solo" && (membersCount || 0) >= 1) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Solo plan allows a single user only. Upgrade to Team to invite collaborators.`,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
if (plan === "team" && (membersCount || 0) >= MAX_TEAM_MEMBER_LIMIT) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Team plan allows up to ${MAX_TEAM_MEMBER_LIMIT} users. Upgrade to Annual to invite more.`,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const invitedUser = await createInvitedUser(
|
||||
supabase,
|
||||
streamServerClient,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tablo of organizationTablos || []) {
|
||||
const channel = streamServerClient.channel("messaging", tablo.id);
|
||||
try {
|
||||
await channel.addMembers([invitedUser.userId]);
|
||||
} catch (error) {
|
||||
console.error("Failed to add invited user to Stream channel:", error);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Invitation sent successfully",
|
||||
limits: {
|
||||
plan,
|
||||
max_tablos_for_solo: MAX_TABLO_LIMIT,
|
||||
max_members_for_team: MAX_TEAM_MEMBER_LIMIT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const getUserRouter = () => {
|
||||
const userRouter = new Hono();
|
||||
|
||||
|
|
@ -268,6 +548,9 @@ export const getUserRouter = () => {
|
|||
userRouter.post("/mark-temporary", ...markTemporary);
|
||||
userRouter.post("/profile/avatar", ...uploadAvatar);
|
||||
userRouter.delete("/profile/avatar", ...deleteAvatar);
|
||||
userRouter.get("/organization", ...getOrganization);
|
||||
userRouter.patch("/organization", ...updateOrganization);
|
||||
userRouter.post("/organization/invite", ...inviteToOrganization);
|
||||
|
||||
return userRouter;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarCheckIcon,
|
||||
CalendarIcon,
|
||||
Circle,
|
||||
|
|
@ -43,14 +42,14 @@ import {
|
|||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useState } from "react";
|
||||
import { Separator } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useLogout } from "../hooks/auth";
|
||||
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
|
||||
import { normalizeBillingPlan, useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { isProd, isStaging } from "../lib/env";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { getXtabloIcon } from "../utils/iconHelpers";
|
||||
|
|
@ -79,7 +78,10 @@ function NavLink({ isActive, children }: NavLinkProps) {
|
|||
"*:data-[ui=notification-badge]:font-semibold",
|
||||
isActive
|
||||
? "bg-purple-100 dark:bg-purple-900/30 font-semibold text-[#804EEC] dark:text-purple-300 *:data-[ui=notification-badge]:bg-transparent"
|
||||
: ["font-medium", "text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
|
||||
: [
|
||||
"font-medium",
|
||||
"text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker",
|
||||
]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -99,7 +101,8 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
const itemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white",
|
||||
default:
|
||||
"text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white",
|
||||
destructive: "text-red-500/80 focus:bg-red-500/80 focus:text-white",
|
||||
},
|
||||
},
|
||||
|
|
@ -301,17 +304,28 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
case "bg-blue-500":
|
||||
return Zap;
|
||||
case "bg-green-500":
|
||||
return Leaf;
|
||||
case "bg-purple-500":
|
||||
return Gem;
|
||||
case "bg-red-500":
|
||||
return Flame;
|
||||
case "bg-yellow-500":
|
||||
return Star;
|
||||
case "bg-indigo-500":
|
||||
return Compass;
|
||||
case "bg-pink-500":
|
||||
return Heart;
|
||||
case "bg-teal-500":
|
||||
return Waves;
|
||||
case "bg-orange-500":
|
||||
return Sun;
|
||||
case "bg-cyan-500":
|
||||
return Sparkles;
|
||||
default:
|
||||
return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -372,16 +386,17 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
const isReadOnly = useIsReadOnlyUser();
|
||||
const user = useUser();
|
||||
const { t } = useTranslation("navigation");
|
||||
const { daysRemaining } = useTrialExpiration();
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
|
||||
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
|
||||
const TEAM_MONTHLY_PRICE_ID =
|
||||
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
|
||||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
|
||||
"";
|
||||
|
||||
// Show upsell when user is still in trial or using freemium tier
|
||||
const shouldShowTrialUpsell =
|
||||
daysRemaining !== null && user.plan === "none" && !user.is_temporary;
|
||||
const shouldShowFreemiumUpsell = user.plan === "free" && !user.is_temporary;
|
||||
const isUrgent = daysRemaining !== null && daysRemaining <= 3;
|
||||
const currentPlan = normalizeBillingPlan(user.plan);
|
||||
|
||||
// Show upsell when organization is on Solo plan
|
||||
const shouldShowSoloUpsell = currentPlan === "solo" && !user.is_temporary;
|
||||
|
||||
type List<T> = T[];
|
||||
|
||||
|
|
@ -484,12 +499,16 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
className="w-full"
|
||||
aria-label={isCollapsed ? label : undefined}
|
||||
>
|
||||
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
|
||||
<div
|
||||
className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}
|
||||
>
|
||||
<span className="[&>svg]:w-6 [&>svg]:h-6">{icon}</span>
|
||||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-base transition-all duration-300 font-normal",
|
||||
isActive ? "text-[#804EEC] dark:text-purple-300" : "text-gray-500 dark:text-gray-300/90",
|
||||
isActive
|
||||
? "text-[#804EEC] dark:text-purple-300"
|
||||
: "text-gray-500 dark:text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -507,45 +526,23 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
{!isCollapsed && <RecentProjectsSection />}
|
||||
|
||||
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
|
||||
{/* Trial upsell message */}
|
||||
{shouldShowTrialUpsell && !isCollapsed && (
|
||||
{/* Solo upsell message */}
|
||||
{shouldShowSoloUpsell && !isCollapsed && (
|
||||
<li className="mb-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
"mx-2 mb-2 p-3 rounded-lg border",
|
||||
isUrgent
|
||||
? "bg-linear-to-br from-red-50 to-orange-50 dark:from-red-950/20 dark:to-orange-950/20 border-red-200 dark:border-red-800"
|
||||
: "bg-linear-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
|
||||
"bg-linear-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
{isUrgent ? (
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-xs font-medium",
|
||||
isUrgent
|
||||
? "text-red-900 dark:text-red-100"
|
||||
: "text-purple-900 dark:text-purple-100"
|
||||
)}
|
||||
>
|
||||
{daysRemaining === 0
|
||||
? "Dernier jour d'essai"
|
||||
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restant${daysRemaining > 1 ? "s" : ""}`}
|
||||
<p className="text-xs font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Solo
|
||||
</p>
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-xs mt-0.5",
|
||||
isUrgent
|
||||
? "text-red-700 dark:text-red-300"
|
||||
: "text-purple-700 dark:text-purple-300"
|
||||
)}
|
||||
>
|
||||
{isUrgent ? "Essayer Starter maintenant" : "Essayer Starter"}
|
||||
<p className="text-xs mt-0.5 text-purple-700 dark:text-purple-300">
|
||||
Passez au plan Team pour inviter jusqu'à 3 utilisateurs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -553,64 +550,20 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
size="sm"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: STANDARD_MONTHLY_PRICE_ID,
|
||||
priceId: TEAM_MONTHLY_PRICE_ID,
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
|
||||
className={twMerge(
|
||||
"w-full h-7 text-xs gap-1",
|
||||
isUrgent
|
||||
? "bg-linear-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600"
|
||||
: "bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
)}
|
||||
disabled={checkoutPending || !TEAM_MONTHLY_PRICE_ID}
|
||||
className="w-full h-7 text-xs gap-1 bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
"..."
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-3 h-3" />
|
||||
Mettre à niveau
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{/* Freemium upsell message */}
|
||||
{shouldShowFreemiumUpsell && !isCollapsed && (
|
||||
<li className="mb-2">
|
||||
<div className="mx-2 mb-2 p-3 rounded-lg border bg-linear-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-blue-900 dark:text-blue-100">
|
||||
Plan Freemium
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 text-blue-700 dark:text-blue-300">
|
||||
Passer au plan Starter pour profiter de projets illimités.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: STANDARD_MONTHLY_PRICE_ID,
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
|
||||
className="w-full h-7 text-xs gap-1 bg-linear-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
"..."
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-3 h-3" />
|
||||
Essayer Starter
|
||||
Passer au plan Team
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -648,11 +601,15 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
aria-label={isCollapsed ? "Feedback" : undefined}
|
||||
>
|
||||
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
|
||||
<span className="[&>svg]:w-6 [&>svg]:h-6"><SendIcon aria-hidden="true" /></span>
|
||||
<span className="[&>svg]:w-6 [&>svg]:h-6">
|
||||
<SendIcon aria-hidden="true" />
|
||||
</span>
|
||||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-base transition-all duration-300 font-normal",
|
||||
location.pathname === "/feedback" ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-300/90",
|
||||
location.pathname === "/feedback"
|
||||
? "text-gray-900 dark:text-white"
|
||||
: "text-gray-500 dark:text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { pluralize } from "@xtablo/shared";
|
||||
import { Badge } from "@xtablo/ui/components/badge";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -9,14 +8,13 @@ import {
|
|||
CardTitle,
|
||||
} from "@xtablo/ui/components/card";
|
||||
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
normalizeBillingPlan,
|
||||
useCancelSubscription,
|
||||
useCreateCheckoutSession,
|
||||
useCreatePortalSession,
|
||||
useReactivateSubscription,
|
||||
useSubscription,
|
||||
useTrialExpiration,
|
||||
} from "../hooks/stripe";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
|
|
@ -39,36 +37,33 @@ export function SubscriptionCard() {
|
|||
const { mutate: reactivateSubscription, isPending: reactivatePending } =
|
||||
useReactivateSubscription();
|
||||
|
||||
const { daysRemaining } = useTrialExpiration();
|
||||
|
||||
const daysRemainingValue = useMemo(() => {
|
||||
if (!daysRemaining) return 7;
|
||||
return daysRemaining;
|
||||
}, [daysRemaining]);
|
||||
|
||||
const isPaying = user.plan === "trial" || user.plan === "standard";
|
||||
const isBeta = user.plan === "beta";
|
||||
const isFreemium = user.plan === "free";
|
||||
|
||||
const showTrialBanner = user.plan === "none";
|
||||
const normalizedPlan = normalizeBillingPlan(user.plan);
|
||||
const isPaying = normalizedPlan === "team" || normalizedPlan === "annual";
|
||||
const isAnnual = normalizedPlan === "annual";
|
||||
const isSolo = normalizedPlan === "solo";
|
||||
|
||||
// Replace with your actual price ID from Stripe Dashboard
|
||||
|
||||
const infinitePriceId = import.meta.env.VITE_STRIPE_INFINITE_PRICE_ID || "";
|
||||
const standardPriceId = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
|
||||
const teamPriceId =
|
||||
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
|
||||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
|
||||
"";
|
||||
const annualPriceId = import.meta.env.VITE_STRIPE_ANNUAL_PRICE_ID || "";
|
||||
const soloPriceId = import.meta.env.VITE_STRIPE_SOLO_PRICE_ID || "";
|
||||
|
||||
const priceId =
|
||||
allowedInfiniteUsers.includes(user.email!) && infinitePriceId
|
||||
? infinitePriceId
|
||||
: standardPriceId;
|
||||
: teamPriceId || annualPriceId || soloPriceId;
|
||||
|
||||
const getStatusBadge = () => {
|
||||
// Check for beta plan first
|
||||
if (isBeta) {
|
||||
// Annual plan badge
|
||||
if (isAnnual) {
|
||||
return (
|
||||
<Badge className="gap-1.5 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Beta
|
||||
Annual
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,11 +119,11 @@ export function SubscriptionCard() {
|
|||
{getStatusBadge()}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{isBeta
|
||||
? "Vous avez accès à toutes les fonctionnalités gratuitement en tant que bêta-testeur"
|
||||
{isAnnual
|
||||
? "Vous disposez du plan Annual avec limites illimitées"
|
||||
: isPaying
|
||||
? "Gérez votre abonnement et votre facturation"
|
||||
: "Passez à Starter pour débloquer toutes les fonctionnalités"}
|
||||
: "Passez au plan Team ou Annual pour travailler en équipe"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -138,24 +133,23 @@ export function SubscriptionCard() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Beta Plan */}
|
||||
{isBeta && (
|
||||
{/* Annual Plan */}
|
||||
{isAnnual && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Beta
|
||||
Plan Annual
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
Accès gratuit et illimité à toutes les fonctionnalités
|
||||
Utilisateurs et tablos illimités
|
||||
</p>
|
||||
<div className="pt-2 border-t border-purple-200 dark:border-purple-800">
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400">
|
||||
Merci de faire partie de nos bêta-testeurs ! Votre retour nous aide à
|
||||
améliorer XTablo.
|
||||
Votre organisation bénéficie des capacités maximales.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -163,16 +157,15 @@ export function SubscriptionCard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showTrialBanner && (
|
||||
{isSolo && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Accès gratuit pendant 7 jours
|
||||
Plan Solo
|
||||
</p>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
Il vous reste {daysRemainingValue} {pluralize("jour", daysRemainingValue)}{" "}
|
||||
pour passer au plan Starter.
|
||||
1 utilisateur et 10 tablos maximum pour votre organisation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,46 +188,7 @@ export function SubscriptionCard() {
|
|||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Passer au plan Starter
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFreemium && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Plan Freemium
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
Un seul tablo disponible gratuitement, passez au plan Starter pour profiter de
|
||||
toutes les fonctionnalités.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId,
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !priceId}
|
||||
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
<>
|
||||
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||
Chargement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Passer au plan Starter
|
||||
Passer au plan Team
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -248,10 +202,10 @@ export function SubscriptionCard() {
|
|||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900 dark:text-green-100">
|
||||
Plan Starter
|
||||
Plan {isAnnual ? "Annual" : "Team"}
|
||||
</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
Toutes les fonctionnalités débloquées
|
||||
Collaboration d'organisation activée
|
||||
</p>
|
||||
</div>
|
||||
{subscription.current_period_end && (
|
||||
|
|
@ -312,7 +266,7 @@ export function SubscriptionCard() {
|
|||
Abonnement en cours d'annulation
|
||||
</p>
|
||||
<p className="text-xs text-orange-700 dark:text-orange-300 mt-1">
|
||||
Votre abonnement Starter sera annulé le{" "}
|
||||
Votre abonnement {isAnnual ? "Annual" : "Team"} sera annulé le{" "}
|
||||
{subscription.current_period_end &&
|
||||
new Date(subscription.current_period_end * 1000).toLocaleDateString(
|
||||
"fr-FR",
|
||||
|
|
@ -324,7 +278,7 @@ export function SubscriptionCard() {
|
|||
)}
|
||||
</p>
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 mt-2">
|
||||
Vous aurez accès aux fonctionnalités Starter jusqu'à cette date.
|
||||
Vous gardez vos droits actuels jusqu'à cette date.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ import {
|
|||
} from "@xtablo/ui/components/dialog";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover";
|
||||
import { Settings, Share2 } from "lucide-react";
|
||||
import { Loader2, Settings, Share2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useTabloMembers, useUpdateTablo } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
// Fetch members and invites for share dialog
|
||||
const { data: members } = useTabloMembers(tablo?.id || "");
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || "");
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -109,8 +110,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
{filteredMembers && filteredMembers.length > 0 && (
|
||||
<div className="flex items-center -space-x-2 mr-2">
|
||||
{filteredMembers.slice(0, 3).map((member) => {
|
||||
const isCurrentUser = member.id === currentUser.id;
|
||||
const avatarUrl = isCurrentUser ? currentUser.avatar_url : null;
|
||||
const avatarUrl =
|
||||
member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null);
|
||||
return (
|
||||
<Avatar
|
||||
key={member.id}
|
||||
|
|
@ -273,6 +274,20 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
{invite.invited_email}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })}
|
||||
disabled={isCancellingInvite}
|
||||
title="Retirer l'invitation"
|
||||
>
|
||||
{isCancellingInvite ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -287,8 +302,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{filteredMembers.map((member, index) => {
|
||||
const isCurrentUser = member.id === currentUser.id;
|
||||
const avatarUrl = isCurrentUser ? currentUser.avatar_url : null;
|
||||
const avatarUrl =
|
||||
member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
|||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites";
|
||||
import {
|
||||
useCancelTabloInvite,
|
||||
usePendingTabloInvitesByTablo,
|
||||
} from "src/hooks/tablo_invites";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
|
@ -16,6 +19,8 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
|
|||
const currentUser = useUser();
|
||||
const { data: members } = useTabloMembers(tablo.id);
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id);
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } =
|
||||
useCancelTabloInvite();
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
|
@ -114,6 +119,16 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
|
|||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">(En attente)</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
cancelInvite({ tabloId: tablo.id, inviteId: invite.id })
|
||||
}
|
||||
disabled={isCancellingInvite}
|
||||
>
|
||||
Retirer
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ export interface TabloMember {
|
|||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
|
|
|||
94
apps/main/src/hooks/organization.ts
Normal file
94
apps/main/src/hooks/organization.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@xtablo/shared";
|
||||
import { useAuthedApi } from "./auth";
|
||||
|
||||
export interface OrganizationSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
plan: string;
|
||||
member_count: number;
|
||||
tablo_count: number;
|
||||
}
|
||||
|
||||
export interface OrganizationMember {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
created_at: string | null;
|
||||
is_temporary: boolean;
|
||||
plan: string | null;
|
||||
}
|
||||
|
||||
export interface OrganizationResponse {
|
||||
organization: OrganizationSummary;
|
||||
members: OrganizationMember[];
|
||||
}
|
||||
|
||||
export const useOrganization = () => {
|
||||
const api = useAuthedApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["organization"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<OrganizationResponse>("/api/v1/users/organization");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateOrganization = () => {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const { data } = await api.patch("/api/v1/users/organization", { name });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
title: "Organisation mise à jour",
|
||||
description: "Le nom de l'organisation a bien été enregistré",
|
||||
type: "success",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["organization"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
title: "Erreur",
|
||||
description: error.message || "Impossible de mettre à jour l'organisation",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInviteOrganizationUser = () => {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
const { data } = await api.post("/api/v1/users/organization/invite", { email });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
title: "Invitation envoyée",
|
||||
description: "L'utilisateur a été invité dans votre organisation",
|
||||
type: "success",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["organization"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
title: "Erreur",
|
||||
description: error.message || "Impossible d'envoyer l'invitation",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -15,7 +15,15 @@ import { useAuthedApi } from "./auth";
|
|||
// Initialize Stripe
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || "");
|
||||
|
||||
const TRIAL_DURATION = 7 * 24 * 60 * 60 * 1000;
|
||||
export type BillingPlan = "solo" | "team" | "annual";
|
||||
|
||||
export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => {
|
||||
if (!plan) return "solo";
|
||||
|
||||
if (plan === "annual" || plan === "beta") return "annual";
|
||||
if (plan === "team" || plan === "standard") return "team";
|
||||
return "solo";
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get user's subscription status from Supabase
|
||||
|
|
@ -54,7 +62,8 @@ export function useSubscription() {
|
|||
export function useIsPayingUser() {
|
||||
const user = useUser();
|
||||
|
||||
const isPaying = user.plan === "trial" || user.plan === "standard";
|
||||
const normalizedPlan = normalizeBillingPlan(user.plan);
|
||||
const isPaying = normalizedPlan === "team" || normalizedPlan === "annual";
|
||||
|
||||
// Direct access from user profile (fastest)
|
||||
return {
|
||||
|
|
@ -66,16 +75,11 @@ export function useIsPayingUser() {
|
|||
export const useIsPastTrial = () => {
|
||||
const user = useMaybeUser();
|
||||
|
||||
// Calculate if user is past trial inline
|
||||
// User is past trial if: plan is 'none' AND created more than 7 days ago
|
||||
// Trial model is deprecated in favor of explicit billing plans.
|
||||
// Keep this hook returning false to avoid blocking existing users unexpectedly.
|
||||
const isPastTrial = useMemo(() => {
|
||||
if (!user) return false;
|
||||
|
||||
const isPastTrial =
|
||||
user.plan === "none" &&
|
||||
new Date(user.created_at || "") < new Date(Date.now() - TRIAL_DURATION);
|
||||
|
||||
return isPastTrial;
|
||||
void user;
|
||||
return false;
|
||||
}, [user]);
|
||||
|
||||
return { isPastTrial, isLoading: !user };
|
||||
|
|
@ -88,24 +92,8 @@ export const useIsPastTrial = () => {
|
|||
export const useTrialExpiration = () => {
|
||||
const user = useMaybeUser();
|
||||
|
||||
const trialExpiration = useMemo(() => {
|
||||
if (!user?.created_at) return null;
|
||||
|
||||
const creationDate = new Date(user.created_at);
|
||||
const expirationDate = new Date(creationDate.getTime() + TRIAL_DURATION);
|
||||
|
||||
return expirationDate;
|
||||
}, [user?.created_at]);
|
||||
|
||||
const daysRemaining = useMemo(() => {
|
||||
if (!trialExpiration) return null;
|
||||
|
||||
const now = new Date();
|
||||
const diffInMs = trialExpiration.getTime() - now.getTime();
|
||||
const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffInDays);
|
||||
}, [trialExpiration]);
|
||||
// Trial model is deprecated in favor of explicit billing plans.
|
||||
const daysRemaining = useMemo(() => null as number | null, [user]);
|
||||
|
||||
return { daysRemaining, isLoading: !user };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@xtablo/shared";
|
||||
import { Database } from "@xtablo/shared/types/database.types";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { useAuthedApi } from "./auth";
|
||||
|
||||
type TabloInvite = Database["public"]["Tables"]["tablo_invites"]["Row"];
|
||||
|
||||
|
|
@ -49,3 +51,37 @@ export const usePendingTabloInvitesByTablo = (tabloId: string) => {
|
|||
enabled: !!user.id && !!tabloId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelTabloInvite = () => {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => {
|
||||
await api.delete(`/api/v1/tablos/invite/${tabloId}/${inviteId}`);
|
||||
},
|
||||
onSuccess: (_data, { tabloId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tablo-invites", tabloId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tablo-members", tabloId] });
|
||||
toast.add(
|
||||
{
|
||||
title: "Invitation retirée",
|
||||
description: "L'invitation en attente a été supprimée",
|
||||
type: "success",
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error cancelling invite:", error);
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: "Impossible de retirer l'invitation",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,7 +52,13 @@ export const useTabloMembers = (tabloId: string) => {
|
|||
queryKey: ["tablo-members", tabloId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{
|
||||
members: { id: string; name: string; is_admin: boolean; email: string }[];
|
||||
members: {
|
||||
id: string;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
}[];
|
||||
}>(`/api/v1/tablos/members/${tabloId}`);
|
||||
return data.members;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,6 +32,25 @@
|
|||
"placeholder": "Write your introduction message here...",
|
||||
"emailDescription": "This message will be sent by email to people you invite"
|
||||
},
|
||||
"organization": {
|
||||
"title": "Organization",
|
||||
"description": "Manage your team workspace",
|
||||
"name": "Organization name",
|
||||
"namePlaceholder": "Your organization name",
|
||||
"currentPlan": "Current plan: {{plan}}",
|
||||
"stats": "{{members}} member(s) · {{tablos}} tablo(s)",
|
||||
"save": "Save organization",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"teamInvite": {
|
||||
"title": "Invite a teammate",
|
||||
"description": "Invite a new user by email to join your organization",
|
||||
"emailLabel": "Teammate email",
|
||||
"emailPlaceholder": "name@company.com",
|
||||
"hint": "The invited user will get credentials by email.",
|
||||
"invite": "Send invite",
|
||||
"inviting": "Sending..."
|
||||
},
|
||||
"cookies": {
|
||||
"title": "Cookie Preferences",
|
||||
"description": "Manage your cookie and tracking preferences. Changes will take effect after page reload.",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,25 @@
|
|||
"placeholder": "Écrivez votre message d'introduction ici...",
|
||||
"emailDescription": "Ce message sera envoyé par email aux personnes que vous invitez"
|
||||
},
|
||||
"organization": {
|
||||
"title": "Organisation",
|
||||
"description": "Gérez votre espace d'équipe",
|
||||
"name": "Nom de l'organisation",
|
||||
"namePlaceholder": "Nom de votre organisation",
|
||||
"currentPlan": "Plan actuel : {{plan}}",
|
||||
"stats": "{{members}} membre(s) · {{tablos}} tablo(s)",
|
||||
"save": "Enregistrer l'organisation",
|
||||
"saving": "Enregistrement..."
|
||||
},
|
||||
"teamInvite": {
|
||||
"title": "Inviter un collaborateur",
|
||||
"description": "Invitez un nouvel utilisateur par email dans votre organisation",
|
||||
"emailLabel": "Email du collaborateur",
|
||||
"emailPlaceholder": "nom@entreprise.com",
|
||||
"hint": "L'utilisateur invité recevra ses identifiants par email.",
|
||||
"invite": "Envoyer l'invitation",
|
||||
"inviting": "Envoi..."
|
||||
},
|
||||
"cookies": {
|
||||
"title": "Préférences des cookies",
|
||||
"description": "Gérez vos préférences en matière de cookies et de suivi. Les modifications prendront effet après rechargement de la page.",
|
||||
|
|
|
|||
|
|
@ -23,11 +23,16 @@ import { Switch } from "@xtablo/ui/components/switch";
|
|||
import { Textarea } from "@xtablo/ui/components/textarea";
|
||||
import { TypographyH3, TypographyMuted, TypographySmall } from "@xtablo/ui/components/typography";
|
||||
import { CameraIcon, CookieIcon, Loader2Icon, Trash2Icon, UploadIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSelector } from "../components/LanguageSelector";
|
||||
import { SubscriptionCard } from "../components/SubscriptionCard";
|
||||
import { useIntroduction } from "../hooks/intros";
|
||||
import {
|
||||
useInviteOrganizationUser,
|
||||
useOrganization,
|
||||
useUpdateOrganization,
|
||||
} from "../hooks/organization";
|
||||
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile";
|
||||
import { useCookieConsent } from "../hooks/useCookieConsent";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
|
@ -45,6 +50,11 @@ export default function SettingsPage() {
|
|||
const { mutate: uploadAvatar } = useUploadAvatar();
|
||||
const { mutateAsync: removeAvatar, isPending: removeAvatarPending } = useRemoveAvatar();
|
||||
const { preferences, saveConsent } = useCookieConsent();
|
||||
const { data: organizationData, isLoading: organizationLoading } = useOrganization();
|
||||
const { mutate: updateOrganization, isPending: updateOrganizationPending } =
|
||||
useUpdateOrganization();
|
||||
const { mutate: inviteOrganizationUser, isPending: inviteOrganizationUserPending } =
|
||||
useInviteOrganizationUser();
|
||||
|
||||
const [firstName, setFirstName] = useState(user?.first_name || "");
|
||||
const [lastName, setLastName] = useState(user?.last_name || "");
|
||||
|
|
@ -53,8 +63,16 @@ export default function SettingsPage() {
|
|||
const [imageToCrop, setImageToCrop] = useState<string | null>(null);
|
||||
const [isCropDialogOpen, setIsCropDialogOpen] = useState(false);
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizationData?.organization?.name) {
|
||||
setOrganizationName(organizationData.organization.name);
|
||||
}
|
||||
}, [organizationData?.organization?.name]);
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
|
|
@ -294,6 +312,83 @@ export default function SettingsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings:organization.title")}</CardTitle>
|
||||
<CardDescription>{t("settings:organization.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="organizationName">{t("settings:organization.name")}</Label>
|
||||
<Input
|
||||
id="organizationName"
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("settings:organization.namePlaceholder")}
|
||||
disabled={organizationLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:organization.currentPlan", {
|
||||
plan: organizationData?.organization?.plan || "solo",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:organization.stats", {
|
||||
members: organizationData?.organization?.member_count || 0,
|
||||
tablos: organizationData?.organization?.tablo_count || 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={updateOrganizationPending || organizationName.trim().length < 2}
|
||||
onClick={() => updateOrganization(organizationName.trim())}
|
||||
>
|
||||
{updateOrganizationPending
|
||||
? t("settings:organization.saving")
|
||||
: t("settings:organization.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings:teamInvite.title")}</CardTitle>
|
||||
<CardDescription>{t("settings:teamInvite.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteOrganizationEmail">
|
||||
{t("settings:teamInvite.emailLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inviteOrganizationEmail"
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder={t("settings:teamInvite.emailPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("settings:teamInvite.hint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={inviteOrganizationUserPending || !inviteEmail.trim()}
|
||||
onClick={() => {
|
||||
inviteOrganizationUser(inviteEmail.trim());
|
||||
setInviteEmail("");
|
||||
}}
|
||||
>
|
||||
{inviteOrganizationUserPending
|
||||
? t("settings:teamInvite.inviting")
|
||||
: t("settings:teamInvite.invite")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Subscription Section */}
|
||||
<SubscriptionCard />
|
||||
|
||||
|
|
|
|||
|
|
@ -57,10 +57,14 @@ import { TabloFilesSection } from "../components/TabloFilesSection";
|
|||
import { TabloTasksSection } from "../components/TabloTasksSection";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useTabloFileNames } from "../hooks/tablo_data";
|
||||
import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import {
|
||||
useCancelTabloInvite,
|
||||
usePendingTabloInvitesByTablo,
|
||||
} from "../hooks/tablo_invites";
|
||||
import { useTabloMembers, useTablosList } from "../hooks/tablos";
|
||||
import {
|
||||
useAllTasks,
|
||||
useCreateEtape,
|
||||
useCreateTask,
|
||||
useTabloEtapes,
|
||||
useUpdateTask,
|
||||
|
|
@ -181,6 +185,8 @@ export const TabloDetailsPage = () => {
|
|||
const currentUser = useUser();
|
||||
const { data: members } = useTabloMembers(tabloId ?? "");
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? "");
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } =
|
||||
useCancelTabloInvite();
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
const isEmailValid = (email: string): boolean => {
|
||||
|
|
@ -580,6 +586,7 @@ export const TabloDetailsPage = () => {
|
|||
etapes={etapes}
|
||||
tabloTasks={tabloTasks}
|
||||
tabloId={tabloId ?? ""}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -670,6 +677,20 @@ export const TabloDetailsPage = () => {
|
|||
{invite.invited_email}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
cancelInvite({
|
||||
tabloId: tabloId ?? "",
|
||||
inviteId: invite.id,
|
||||
})
|
||||
}
|
||||
disabled={isCancellingInvite || !tabloId}
|
||||
title="Retirer l'invitation"
|
||||
>
|
||||
{isCancellingInvite ? "..." : "Retirer"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -684,10 +705,11 @@ export const TabloDetailsPage = () => {
|
|||
</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{filteredMembers.map((member) => {
|
||||
const isCurrentUser = member.id === currentUser.id;
|
||||
const avatarUrl = isCurrentUser
|
||||
? currentUser.avatar_url
|
||||
: null;
|
||||
const avatarUrl =
|
||||
member.avatar_url ??
|
||||
(member.id === currentUser.id
|
||||
? currentUser.avatar_url
|
||||
: null);
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
|
|
@ -728,10 +750,12 @@ function EtapesSection({
|
|||
etapes,
|
||||
tabloTasks,
|
||||
tabloId,
|
||||
isAdmin,
|
||||
}: {
|
||||
etapes: Etape[];
|
||||
tabloTasks: KanbanTask[];
|
||||
tabloId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
|
||||
new Set(etapes.map((e) => e.id)),
|
||||
|
|
@ -739,8 +763,11 @@ function EtapesSection({
|
|||
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [newEtapeTitle, setNewEtapeTitle] = useState("");
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
const { mutateAsync: createEtape, isPending: isCreatingEtape } =
|
||||
useCreateEtape();
|
||||
|
||||
const toggleEtape = (id: string) => {
|
||||
setExpandedEtapes((prev) => {
|
||||
|
|
@ -766,6 +793,24 @@ function EtapesSection({
|
|||
setAddingTaskToEtape(null);
|
||||
};
|
||||
|
||||
const handleAddEtape = async () => {
|
||||
const title = newEtapeTitle.trim();
|
||||
if (!title || !tabloId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPosition =
|
||||
etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
|
||||
|
||||
await createEtape({
|
||||
tabloId,
|
||||
title,
|
||||
position: nextPosition,
|
||||
});
|
||||
|
||||
setNewEtapeTitle("");
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
todo: {
|
||||
label: "À faire",
|
||||
|
|
@ -788,23 +833,43 @@ function EtapesSection({
|
|||
},
|
||||
};
|
||||
|
||||
if (etapes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
|
||||
Aucune étape
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
Les étapes permettent de structurer votre projet en grandes phases
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{etapes.map((etape, index) => {
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newEtapeTitle}
|
||||
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
||||
placeholder="Nom de la nouvelle étape..."
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
void handleAddEtape();
|
||||
}
|
||||
}}
|
||||
className="h-9 sm:w-80"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void handleAddEtape()}
|
||||
disabled={isCreatingEtape || !newEtapeTitle.trim()}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une étape
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etapes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
|
||||
Aucune étape
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
Les étapes permettent de structurer votre projet en grandes phases
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
etapes.map((etape, index) => {
|
||||
const childTasks = tabloTasks.filter(
|
||||
(t) => t.parent_task_id === etape.id,
|
||||
);
|
||||
|
|
@ -1030,7 +1095,8 @@ function EtapesSection({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,433 @@
|
|||
-- Organizations: team collaboration model
|
||||
-- - Every user belongs to exactly one organization
|
||||
-- - Tablos are created by users but owned by organizations
|
||||
-- - Organization members automatically get admin access to organization tablos
|
||||
|
||||
-- ============================================================================
|
||||
-- Organizations
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.organizations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
internal_uuid uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
|
||||
name text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.organizations IS 'Organizations grouping users from the same company for shared tablo access.';
|
||||
COMMENT ON COLUMN public.organizations.id IS 'Primary key (SERIAL), external references should use this value.';
|
||||
COMMENT ON COLUMN public.organizations.internal_uuid IS 'Internal backup UUID for organization records.';
|
||||
|
||||
DROP TRIGGER IF EXISTS update_organizations_updated_at ON public.organizations;
|
||||
CREATE TRIGGER update_organizations_updated_at
|
||||
BEFORE UPDATE ON public.organizations
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- Random two-word English organization names
|
||||
CREATE OR REPLACE FUNCTION public.generate_cool_organization_name() RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
adjectives text[] := ARRAY[
|
||||
'Amber', 'Arctic', 'Atlas', 'Azure', 'Bold', 'Bright', 'Cobalt', 'Cosmic',
|
||||
'Crisp', 'Dynamic', 'Electric', 'Emerald', 'Epic', 'Golden', 'Grand', 'Iron',
|
||||
'Ivory', 'Lunar', 'Modern', 'Nova', 'Ocean', 'Orbit', 'Prime', 'Quantum',
|
||||
'Rapid', 'Royal', 'Silver', 'Solar', 'Summit', 'Swift', 'Ultra', 'Urban', 'Velvet'
|
||||
];
|
||||
nouns text[] := ARRAY[
|
||||
'Bridge', 'Cloud', 'Collective', 'Compass', 'Craft', 'Forge', 'Frontier', 'Harbor',
|
||||
'Horizon', 'House', 'Labs', 'Line', 'Logic', 'Matrix', 'Minds', 'Network',
|
||||
'Nexus', 'Peak', 'Pioneer', 'Pulse', 'Reach', 'Rocket', 'Scope', 'Signal',
|
||||
'Spark', 'Sphere', 'Stack', 'Studio', 'Systems', 'Tower', 'Ventures', 'Works', 'Yard'
|
||||
];
|
||||
BEGIN
|
||||
RETURN
|
||||
adjectives[1 + floor(random() * array_length(adjectives, 1))::integer]
|
||||
|| ' ' ||
|
||||
nouns[1 + floor(random() * array_length(nouns, 1))::integer];
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.generate_cool_organization_name() IS 'Returns a random two-word English organization name.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_personal_organization() RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
new_org_id integer;
|
||||
BEGIN
|
||||
INSERT INTO public.organizations (name)
|
||||
VALUES (public.generate_cool_organization_name())
|
||||
RETURNING id INTO new_org_id;
|
||||
|
||||
RETURN new_org_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.create_personal_organization() OWNER TO postgres;
|
||||
|
||||
-- ============================================================================
|
||||
-- Profiles: one organization per user
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS organization_id integer;
|
||||
|
||||
-- Existing users: create one personal organization each
|
||||
UPDATE public.profiles
|
||||
SET organization_id = public.create_personal_organization()
|
||||
WHERE organization_id IS NULL;
|
||||
|
||||
-- Ensure future profile inserts always receive an organization
|
||||
CREATE OR REPLACE FUNCTION public.ensure_profile_organization() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.organization_id IS NULL THEN
|
||||
NEW.organization_id := public.create_personal_organization();
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.ensure_profile_organization() OWNER TO postgres;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_ensure_profile_organization ON public.profiles;
|
||||
CREATE TRIGGER trigger_ensure_profile_organization
|
||||
BEFORE INSERT ON public.profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.ensure_profile_organization();
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ALTER COLUMN organization_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
DROP CONSTRAINT IF EXISTS profiles_organization_id_fkey;
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD CONSTRAINT profiles_organization_id_fkey
|
||||
FOREIGN KEY (organization_id)
|
||||
REFERENCES public.organizations(id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS profiles_organization_id_idx ON public.profiles (organization_id);
|
||||
|
||||
COMMENT ON COLUMN public.profiles.organization_id IS 'Organization the user belongs to. A user can only belong to one organization.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Tablos: owned by organizations
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE public.tablos
|
||||
ADD COLUMN IF NOT EXISTS organization_id integer;
|
||||
|
||||
-- Backfill existing tablos using owner's organization
|
||||
UPDATE public.tablos t
|
||||
SET organization_id = p.organization_id
|
||||
FROM public.profiles p
|
||||
WHERE p.id = t.owner_id
|
||||
AND t.organization_id IS NULL;
|
||||
|
||||
-- Safety net for legacy inconsistencies: some historical tablos can reference
|
||||
-- owners that do not have a profile row anymore. Assign these orphaned rows to
|
||||
-- a dedicated recovery organization so the NOT NULL constraint can be applied.
|
||||
DO $$
|
||||
DECLARE
|
||||
recovery_organization_id integer;
|
||||
has_notify_trigger boolean;
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM public.tablos WHERE organization_id IS NULL) THEN
|
||||
INSERT INTO public.organizations (name)
|
||||
VALUES ('Recovered Legacy Workspace')
|
||||
RETURNING id INTO recovery_organization_id;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_trigger
|
||||
WHERE tgrelid = 'public.tablos'::regclass
|
||||
AND tgname = 'notify_users_on_tablos'
|
||||
AND NOT tgisinternal
|
||||
) INTO has_notify_trigger;
|
||||
|
||||
BEGIN
|
||||
IF has_notify_trigger THEN
|
||||
EXECUTE 'ALTER TABLE public.tablos DISABLE TRIGGER notify_users_on_tablos';
|
||||
END IF;
|
||||
|
||||
UPDATE public.tablos t
|
||||
SET organization_id = recovery_organization_id
|
||||
WHERE t.organization_id IS NULL;
|
||||
|
||||
IF has_notify_trigger THEN
|
||||
EXECUTE 'ALTER TABLE public.tablos ENABLE TRIGGER notify_users_on_tablos';
|
||||
END IF;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
IF has_notify_trigger THEN
|
||||
EXECUTE 'ALTER TABLE public.tablos ENABLE TRIGGER notify_users_on_tablos';
|
||||
END IF;
|
||||
RAISE;
|
||||
END;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Ensure future tablos inherit owner's organization when omitted
|
||||
CREATE OR REPLACE FUNCTION public.set_tablo_organization_from_owner() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
owner_organization_id integer;
|
||||
BEGIN
|
||||
IF NEW.organization_id IS NULL THEN
|
||||
SELECT p.organization_id
|
||||
INTO owner_organization_id
|
||||
FROM public.profiles p
|
||||
WHERE p.id = NEW.owner_id
|
||||
LIMIT 1;
|
||||
|
||||
IF owner_organization_id IS NULL THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23503',
|
||||
MESSAGE = 'Tablo owner has no organization';
|
||||
END IF;
|
||||
|
||||
NEW.organization_id := owner_organization_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.set_tablo_organization_from_owner() OWNER TO postgres;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_set_tablo_organization ON public.tablos;
|
||||
CREATE TRIGGER trigger_set_tablo_organization
|
||||
BEFORE INSERT ON public.tablos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_tablo_organization_from_owner();
|
||||
|
||||
ALTER TABLE public.tablos
|
||||
ALTER COLUMN organization_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE public.tablos
|
||||
DROP CONSTRAINT IF EXISTS tablos_organization_id_fkey;
|
||||
|
||||
ALTER TABLE public.tablos
|
||||
ADD CONSTRAINT tablos_organization_id_fkey
|
||||
FOREIGN KEY (organization_id)
|
||||
REFERENCES public.organizations(id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tablos_organization_id_idx ON public.tablos (organization_id);
|
||||
|
||||
COMMENT ON COLUMN public.tablos.organization_id IS 'Organization that owns the tablo.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Access synchronization for organization members
|
||||
-- ============================================================================
|
||||
|
||||
-- Replace owner-only access creation with org-wide admin access creation
|
||||
CREATE OR REPLACE FUNCTION public.create_tablo_access_for_owner() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.tablo_access (
|
||||
tablo_id,
|
||||
user_id,
|
||||
granted_by,
|
||||
is_active,
|
||||
is_admin
|
||||
)
|
||||
SELECT
|
||||
NEW.id,
|
||||
p.id,
|
||||
NEW.owner_id,
|
||||
TRUE,
|
||||
TRUE
|
||||
FROM public.profiles p
|
||||
WHERE p.organization_id = NEW.organization_id
|
||||
ON CONFLICT (tablo_id, user_id)
|
||||
DO UPDATE SET
|
||||
is_active = TRUE,
|
||||
is_admin = TRUE,
|
||||
granted_by = EXCLUDED.granted_by;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.create_tablo_access_for_owner() OWNER TO postgres;
|
||||
|
||||
-- Existing data: ensure every org member has admin access to org tablos
|
||||
INSERT INTO public.tablo_access (
|
||||
tablo_id,
|
||||
user_id,
|
||||
granted_by,
|
||||
is_active,
|
||||
is_admin
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
p.id,
|
||||
t.owner_id,
|
||||
TRUE,
|
||||
TRUE
|
||||
FROM public.tablos t
|
||||
JOIN public.profiles p
|
||||
ON p.organization_id = t.organization_id
|
||||
WHERE t.deleted_at IS NULL
|
||||
ON CONFLICT (tablo_id, user_id)
|
||||
DO UPDATE SET
|
||||
is_active = TRUE,
|
||||
is_admin = TRUE,
|
||||
granted_by = EXCLUDED.granted_by;
|
||||
|
||||
-- When a user joins/moves organization, sync tablo_access automatically
|
||||
CREATE OR REPLACE FUNCTION public.sync_org_member_tablo_access() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' AND NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN
|
||||
UPDATE public.tablo_access ta
|
||||
SET is_active = FALSE
|
||||
WHERE ta.user_id = NEW.id
|
||||
AND ta.is_admin = TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.tablos t
|
||||
WHERE t.id = ta.tablo_id
|
||||
AND t.organization_id = OLD.organization_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.tablo_access (
|
||||
tablo_id,
|
||||
user_id,
|
||||
granted_by,
|
||||
is_active,
|
||||
is_admin
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
NEW.id,
|
||||
NEW.id,
|
||||
TRUE,
|
||||
TRUE
|
||||
FROM public.tablos t
|
||||
WHERE t.organization_id = NEW.organization_id
|
||||
AND t.deleted_at IS NULL
|
||||
ON CONFLICT (tablo_id, user_id)
|
||||
DO UPDATE SET
|
||||
is_active = TRUE,
|
||||
is_admin = TRUE,
|
||||
granted_by = EXCLUDED.granted_by;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.sync_org_member_tablo_access() OWNER TO postgres;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_sync_org_member_tablo_access ON public.profiles;
|
||||
CREATE TRIGGER trigger_sync_org_member_tablo_access
|
||||
AFTER INSERT OR UPDATE OF organization_id ON public.profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.sync_org_member_tablo_access();
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS updates
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.current_user_organization_id() RETURNS integer
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT p.organization_id
|
||||
FROM public.profiles p
|
||||
WHERE p.id = auth.uid();
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.current_user_organization_id() OWNER TO postgres;
|
||||
|
||||
-- Organizations RLS
|
||||
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Users can view their own organization" ON public.organizations;
|
||||
CREATE POLICY "Users can view their own organization"
|
||||
ON public.organizations
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (id = public.current_user_organization_id());
|
||||
|
||||
DROP POLICY IF EXISTS "Users can update their own organization" ON public.organizations;
|
||||
CREATE POLICY "Users can update their own organization"
|
||||
ON public.organizations
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (id = public.current_user_organization_id())
|
||||
WITH CHECK (id = public.current_user_organization_id());
|
||||
|
||||
-- Tablos RLS: organization members have owner-level access
|
||||
DROP POLICY IF EXISTS "Users can view tablos they have access to" ON public.tablos;
|
||||
CREATE POLICY "Users can view tablos they have access to"
|
||||
ON public.tablos
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
organization_id = public.current_user_organization_id()
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM public.tablo_access ta
|
||||
WHERE ta.tablo_id = tablos.id
|
||||
AND ta.user_id = auth.uid()
|
||||
AND ta.is_active = TRUE
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Users can insert own tablos" ON public.tablos;
|
||||
CREATE POLICY "Users can insert own tablos"
|
||||
ON public.tablos
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND (
|
||||
organization_id = public.current_user_organization_id()
|
||||
OR organization_id IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Users can update own tablos" ON public.tablos;
|
||||
CREATE POLICY "Users can update own tablos"
|
||||
ON public.tablos
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (organization_id = public.current_user_organization_id())
|
||||
WITH CHECK (organization_id = public.current_user_organization_id());
|
||||
|
||||
-- Tasks RLS already relies on tablo_access membership. Org-level tablo_access sync above
|
||||
-- gives all organization members full access (admin) to organization tasks.
|
||||
|
||||
-- ============================================================================
|
||||
-- Plan aliases for new billing model
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'solo';
|
||||
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'team';
|
||||
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'annual';
|
||||
|
||||
COMMENT ON TYPE public.subscription_plan IS 'Billing plans: solo (1 user, 10 tablos), team (3 users, unlimited tablos), annual (unlimited). Legacy values are kept for compatibility.';
|
||||
|
||||
GRANT SELECT, UPDATE ON public.organizations TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.current_user_organization_id() TO authenticated;
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
-- Update Stripe subscription -> profile.plan mapping for the new billing model
|
||||
-- Plans are now: solo, team, annual.
|
||||
-- Legacy plan values can still exist, but new webhook sync should assign the new values.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_profile_subscription_status() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_customer_id text;
|
||||
v_plan_hint text;
|
||||
v_has_paid_subscription boolean := FALSE;
|
||||
v_plan public.subscription_plan := 'solo';
|
||||
BEGIN
|
||||
-- Resolve customer id from the changed stripe row
|
||||
IF TG_TABLE_NAME = 'subscriptions' THEN
|
||||
v_customer_id := NEW.customer;
|
||||
ELSIF TG_TABLE_NAME = 'subscription_items' THEN
|
||||
SELECT s.customer
|
||||
INTO v_customer_id
|
||||
FROM stripe.subscriptions s
|
||||
WHERE s.id = NEW.subscription;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF v_customer_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Resolve application user id from Stripe customer metadata
|
||||
SELECT (c.metadata->>'user_id')::uuid
|
||||
INTO v_user_id
|
||||
FROM stripe.customers c
|
||||
WHERE c.id = v_customer_id;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Pick the best active/trialing subscription for this user and infer plan from
|
||||
-- price/product metadata or lookup key. Fall back to Team when paid but unmapped.
|
||||
WITH candidate_subscriptions AS (
|
||||
SELECT
|
||||
s.status::text AS status,
|
||||
lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) AS plan_hint,
|
||||
CASE s.status::text
|
||||
WHEN 'active' THEN 3
|
||||
WHEN 'past_due' THEN 2
|
||||
WHEN 'trialing' THEN 1
|
||||
ELSE 0
|
||||
END AS status_weight,
|
||||
CASE
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%annual%' THEN 3
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%team%' OR lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%standard%' THEN 2
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%solo%' THEN 1
|
||||
ELSE 0
|
||||
END AS plan_weight
|
||||
FROM stripe.subscriptions s
|
||||
JOIN stripe.customers c
|
||||
ON c.id = s.customer
|
||||
LEFT JOIN stripe.subscription_items si
|
||||
ON si.subscription = s.id
|
||||
LEFT JOIN stripe.prices pr
|
||||
ON pr.id = si.price
|
||||
LEFT JOIN stripe.products pd
|
||||
ON pd.id = pr.product
|
||||
WHERE (c.metadata->>'user_id')::uuid = v_user_id
|
||||
AND s.status::text IN ('active', 'past_due', 'trialing')
|
||||
AND (
|
||||
si.current_period_end IS NULL
|
||||
OR to_timestamp(si.current_period_end) > now()
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
TRUE,
|
||||
cs.plan_hint
|
||||
INTO v_has_paid_subscription, v_plan_hint
|
||||
FROM candidate_subscriptions cs
|
||||
ORDER BY cs.plan_weight DESC, cs.status_weight DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_has_paid_subscription THEN
|
||||
IF v_plan_hint LIKE '%annual%' OR v_plan_hint LIKE '%beta%' THEN
|
||||
v_plan := 'annual';
|
||||
ELSIF v_plan_hint LIKE '%team%' OR v_plan_hint LIKE '%standard%' THEN
|
||||
v_plan := 'team';
|
||||
ELSIF v_plan_hint LIKE '%solo%' OR v_plan_hint LIKE '%free%' THEN
|
||||
v_plan := 'solo';
|
||||
ELSE
|
||||
-- paid but unmapped => Team by default (backward compatibility)
|
||||
v_plan := 'team';
|
||||
END IF;
|
||||
ELSE
|
||||
v_plan := 'solo';
|
||||
END IF;
|
||||
|
||||
UPDATE public.profiles
|
||||
SET plan = v_plan
|
||||
WHERE id = v_user_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.update_profile_subscription_status() OWNER TO postgres;
|
||||
|
||||
COMMENT ON FUNCTION public.update_profile_subscription_status() IS
|
||||
'Maps Stripe subscription state to profile.plan using new billing plans (solo, team, annual).';
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
-- Allow users to read profiles of collaborators that share at least one active tablo.
|
||||
-- This unblocks assignee avatars/names in tasks_with_assignee while keeping profile
|
||||
-- visibility scoped to collaboration relationships.
|
||||
|
||||
DROP POLICY IF EXISTS "Users can view shared tablo member profiles" ON public.profiles;
|
||||
|
||||
CREATE POLICY "Users can view shared tablo member profiles"
|
||||
ON public.profiles
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.tablo_access viewer_access
|
||||
JOIN public.tablo_access member_access
|
||||
ON member_access.tablo_id = viewer_access.tablo_id
|
||||
WHERE viewer_access.user_id = auth.uid()
|
||||
AND viewer_access.is_active = TRUE
|
||||
AND member_access.user_id = profiles.id
|
||||
AND member_access.is_active = TRUE
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue