refactor(api): remove all Stream Chat dependencies and operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-11 13:44:30 +02:00
parent 54a13c3c30
commit 37a94ef2b3
No known key found for this signature in database
17 changed files with 10 additions and 554 deletions

View file

@ -33,7 +33,6 @@
"multer": "^2.0.2",
"nodemailer": "^7.0.4",
"sharp": "^0.34.5",
"stream-chat": "^9.8.0",
"stripe": "^20.0.0",
"ts-node": "^10.9.2"
},

View file

@ -10,10 +10,8 @@ const baseSecrets: Secrets = {
emailRefreshToken: "email-refresh-token",
r2AccessKeyId: "r2-access-key-id",
r2SecretAccessKey: "r2-secret-access-key",
streamChatApiSecret: "stream-chat-api-secret",
stripeSecretKey: "sk_live_secret_manager",
stripeWebhookSecret: "whsec_live_secret_manager",
streamChatApiSecretStaging: "stream-chat-api-secret-staging",
stripeSecretKeyStaging: "sk_live_staging_secret_manager",
stripeWebhookSecretStaging: "whsec_live_staging_secret_manager",
};

View file

@ -427,26 +427,6 @@ describe("Middleware Tests", () => {
});
});
describe("StreamChat Middleware", () => {
it("should inject StreamChat client into context", async () => {
const app = new Hono();
app.use(middlewareManager.streamChat);
app.get("/test", (c) => {
const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("streamServerClient");
return c.json({ hasStreamClient: !!streamClient });
});
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
expect(res.status).toBe(200);
expect(data.hasStreamClient).toBe(true);
});
});
describe("R2 Middleware", () => {
it("should inject S3 client into context", async () => {
const app = new Hono();
@ -531,18 +511,14 @@ describe("Middleware Tests", () => {
it("should chain multiple middlewares correctly", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.streamChat);
app.use(middlewareManager.stripe);
app.get("/test", (c) => {
const supabase = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("supabase");
const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("streamServerClient");
const stripe = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("stripe");
return c.json({
hasSupabase: !!supabase,
hasStreamClient: !!streamClient,
hasStripe: !!stripe,
});
});
@ -554,7 +530,6 @@ describe("Middleware Tests", () => {
expect(res.status).toBe(200);
expect(data.hasSupabase).toBe(true);
expect(data.hasStreamClient).toBe(true);
expect(data.hasStripe).toBe(true);
});
@ -562,7 +537,6 @@ describe("Middleware Tests", () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth); // This will fail
app.use(middlewareManager.streamChat); // This should not execute
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access

View file

@ -1,31 +1,11 @@
import { createClient } from "@supabase/supabase-js";
import { testClient } from "hono/testing";
import type { Channel, StreamChat } from "stream-chat";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { getTestUser } from "../helpers/dbSetup.js";
// Mock the stream-chat module
vi.mock("stream-chat", () => {
const mockChannel = {
create: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
};
const mockStreamChatInstance = {
channel: vi.fn(() => mockChannel),
upsertUser: vi.fn().mockResolvedValue({ users: {} }),
};
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstance),
},
};
});
// Mock nodemailer
const mockSendMail = vi.fn();
vi.mock("nodemailer", () => ({
@ -54,16 +34,7 @@ describe("Booking Endpoint", () => {
const createdTablos: string[] = [];
const createdUsers: string[] = [];
// Get references to the mocked functions for assertions
let mockStreamChat: StreamChat;
let mockChannel: Channel;
beforeAll(async () => {
// Get references to the mocked instances
const { StreamChat } = await import("stream-chat");
mockStreamChat = StreamChat.getInstance("test_api_key", "test_api_secret");
mockChannel = mockStreamChat.channel("messaging", "test_channel_id");
// Get owner's short_user_id
const { data: ownerProfile } = await supabase
.from("profiles")
@ -324,10 +295,6 @@ describe("Booking Endpoint", () => {
createdUsers.push(userProfile.id);
}
// Verify Stream Chat channel was created
expect(mockChannel.create).toHaveBeenCalledTimes(1);
expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1);
// Verify emails were sent (3 emails: welcome to new user, one to owner, one to booker)
expect(mockSendMail).toHaveBeenCalledTimes(3);
@ -407,10 +374,6 @@ describe("Booking Endpoint", () => {
createdTablos.push(data.tablo_id);
createdBookings.push(data.tablo_id);
// Verify Stream Chat channel was created
expect(mockChannel.create).toHaveBeenCalledTimes(1);
expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1);
// Verify emails were sent (2 emails: one to owner, one to booker)
expect(mockSendMail).toHaveBeenCalledTimes(2);
@ -511,9 +474,6 @@ describe("Booking Endpoint", () => {
expect(data2.tablo_id).toBe(firstTabloId);
expect(data2.hasCreatedAccount).toBe(false);
// Stream Chat channel should still be created for the second booking
expect(mockChannel.create).toHaveBeenCalledTimes(1);
// Verify emails were sent for second booking (2 emails)
expect(mockSendMail).toHaveBeenCalledTimes(2);

View file

@ -8,35 +8,6 @@ import { getMainRouter } from "../../routers/index.js";
import type { TestUserData } from "../helpers/dbSetup.js";
import { getTestUser } from "../helpers/dbSetup.js";
// Mock Stream Chat operations
const mockChannelCreate = vi.fn();
const mockChannelUpdate = vi.fn();
const mockChannelDelete = vi.fn();
const mockChannelRemoveMembers = vi.fn();
const mockChannelAddMembers = vi.fn();
// Mock the channel method to return our mocked channel
const mockChannel = {
create: mockChannelCreate,
update: mockChannelUpdate,
delete: mockChannelDelete,
removeMembers: mockChannelRemoveMembers,
addMembers: mockChannelAddMembers,
};
// Mock the stream-chat module
vi.mock("stream-chat", () => {
const mockStreamChatInstance = {
channel: vi.fn(() => mockChannel),
};
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstance),
},
};
});
// Mock nodemailer for email sending
const mockSendMail = vi.fn();
vi.mock("nodemailer", () => ({
@ -67,11 +38,6 @@ describe("Tablo Endpoint", () => {
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
mockChannelCreate.mockResolvedValue(undefined);
mockChannelUpdate.mockResolvedValue(undefined);
mockChannelDelete.mockResolvedValue(undefined);
mockChannelRemoveMembers.mockResolvedValue(undefined);
mockChannelAddMembers.mockResolvedValue(undefined);
mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
});
@ -195,7 +161,7 @@ describe("Tablo Endpoint", () => {
await supabaseAdmin.from("profiles").update({ plan: "standard" }).eq("id", ownerUser.userId);
});
it("should allow owner to create a tablo and create a Stream Chat channel", async () => {
it("should allow owner to create a tablo", async () => {
const res = await createTabloRequest(ownerUser, client, {
name: "New Owner Tablo",
status: "todo",
@ -205,11 +171,6 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo created successfully");
// Verify Stream Chat channel was created
expect(mockChannelCreate).toHaveBeenCalledTimes(1);
// Verify it was called (the channel is created with tablo data)
expect(mockChannelCreate).toHaveBeenCalled();
});
it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => {
@ -323,7 +284,6 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toBe("You have reached your tablo limit");
expect(mockChannelCreate).not.toHaveBeenCalled();
} finally {
await supabaseAdmin
.from("profiles")
@ -392,17 +352,13 @@ describe("Tablo Endpoint", () => {
});
describe("DELETE /tablos/delete - Delete Tablo", () => {
it("should allow owner with admin access to delete tablo and delete Stream Chat channel", async () => {
it("should allow owner with admin access to delete tablo", async () => {
// Owner has admin access to their tablos
const res = await deleteTabloRequest(ownerUser, client, "test_tablo_owner_private");
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo deleted successfully");
// Verify Stream Chat channel was deleted
expect(mockChannelDelete).toHaveBeenCalledTimes(1);
expect(mockChannelDelete).toHaveBeenCalled();
});
it("should deny temp user without admin access from deleting tablo", async () => {
@ -558,7 +514,7 @@ describe("Tablo Endpoint", () => {
return tabloId;
};
it("should allow temp user to leave a shared tablo and remove from Stream Chat channel", async () => {
it("should allow temp user to leave a shared tablo", async () => {
const tabloId = await createSharedTabloForLeaveTest({
ownerId: ownerUser.userId,
memberId: temporaryUser.userId,
@ -569,13 +525,9 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo left successfully");
// Verify Stream Chat channel removeMembers was called
expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1);
expect(mockChannelRemoveMembers).toHaveBeenCalledWith([temporaryUser.userId]);
});
it("should allow owner to leave a tablo and remove from Stream Chat channel", async () => {
it("should allow owner to leave a tablo", async () => {
const tabloId = await createSharedTabloForLeaveTest({
ownerId: temporaryUser.userId,
memberId: ownerUser.userId,
@ -587,10 +539,6 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo left successfully");
// Verify Stream Chat channel removeMembers was called
expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1);
expect(mockChannelRemoveMembers).toHaveBeenCalledWith([ownerUser.userId]);
});
it("should deny unauthenticated leave request", async () => {

View file

@ -6,27 +6,6 @@ import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
// Mock Stream Chat operations
const mockChannelUpdate = vi.fn();
// Mock the channel method to return our mocked channel
const mockChannel = {
update: mockChannelUpdate,
};
// Mock the stream-chat module
vi.mock("stream-chat", () => {
const mockStreamChatInstance = {
channel: vi.fn(() => mockChannel),
};
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstance),
},
};
});
// Create S3 mock for calendar file operations
const s3Mock = mockClient(S3Client);
@ -45,9 +24,6 @@ describe("Tasks Endpoint", () => {
// Mock PutObjectCommand for calendar file writes
s3Mock.on(PutObjectCommand).resolves({});
// Mock Stream Chat channel update
mockChannelUpdate.mockResolvedValue(undefined);
});
describe("POST /tasks/sync-calendars - Sync Calendar Files", () => {
@ -107,8 +83,8 @@ describe("Tasks Endpoint", () => {
});
});
describe("POST /tasks/sync-tablo-names - Sync Tablo Names to Stream", () => {
it("should call sync tablo names endpoint with basic auth and update Stream Chat channels (returns 200 if TASKS_SECRET properly configured)", async () => {
describe("POST /tasks/sync-tablo-names - Sync Tablo Names", () => {
it("should call sync tablo names endpoint with basic auth (returns 200 if TASKS_SECRET properly configured)", async () => {
const res = await client.tasks["sync-tablo-names"].$post(
{},
{

View file

@ -12,25 +12,6 @@ import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { getTestUser } from "../helpers/dbSetup.js";
// Mock Stream Chat operations
const mockUpsertUser = vi.fn();
const mockCreateToken = vi.fn();
// Create an instance object that holds the mocks (like the working pattern in tablo.test.ts)
const mockStreamChatInstanceMethods = {
upsertUser: mockUpsertUser,
createToken: mockCreateToken,
};
// Mock the stream-chat module
vi.mock("stream-chat", () => {
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstanceMethods),
},
};
});
// Create S3 mock for avatar operations
const s3Mock = mockClient(S3Client);
@ -50,10 +31,6 @@ describe("User Endpoint", () => {
vi.clearAllMocks();
s3Mock.reset();
// Mock Stream Chat operations
mockUpsertUser.mockResolvedValue({ users: { [ownerUser.userId]: {} } });
mockCreateToken.mockReturnValue("mock-stream-token-123");
// Mock S3 operations
s3Mock.on(PutObjectCommand).resolves({});
s3Mock.on(ListObjectsV2Command).resolves({
@ -63,7 +40,7 @@ describe("User Endpoint", () => {
});
describe("GET /me - Get User Profile", () => {
it("should return owner user profile with stream token", async () => {
it("should return owner user profile", async () => {
const res = await client.users.me.$get(
{},
{
@ -78,14 +55,9 @@ describe("User Endpoint", () => {
const data = await res.json();
expect(data.id).toBe(ownerUser.userId);
expect(data.email).toBe(ownerUser.email);
expect(data.streamToken).toBe("mock-stream-token-123");
// Verify Stream Chat createToken was called
expect(mockCreateToken).toHaveBeenCalledTimes(1);
expect(mockCreateToken).toHaveBeenCalledWith(ownerUser.userId);
});
it("should return temp user profile with stream token", async () => {
it("should return temp user profile", async () => {
const res = await client.users.me.$get(
{},
{
@ -100,11 +72,6 @@ describe("User Endpoint", () => {
const data = await res.json();
expect(data.id).toBe(temporaryUser.userId);
expect(data.email).toBe(temporaryUser.email);
expect(data.streamToken).toBe("mock-stream-token-123");
// Verify Stream Chat createToken was called
expect(mockCreateToken).toHaveBeenCalledTimes(1);
expect(mockCreateToken).toHaveBeenCalledWith(temporaryUser.userId);
});
it("should deny unauthenticated access", async () => {
@ -114,63 +81,6 @@ describe("User Endpoint", () => {
});
});
describe("POST /sign-up-to-stream - Sign Up User to Stream Chat", () => {
it("should sign up owner user to stream chat", async () => {
const res = await client.users["sign-up-to-stream"].$post(
{},
{
headers: {
Authorization: `Bearer ${ownerUser.accessToken}`,
"Content-Type": "application/json",
},
}
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("User signed up to stream");
// Verify Stream Chat upsertUser was called
expect(mockUpsertUser).toHaveBeenCalledTimes(1);
expect(mockUpsertUser).toHaveBeenCalledWith({
id: ownerUser.userId,
name: expect.any(String),
language: "fr",
});
});
it("should sign up temp user to stream chat", async () => {
const res = await client.users["sign-up-to-stream"].$post(
{},
{
headers: {
Authorization: `Bearer ${temporaryUser.accessToken}`,
"Content-Type": "application/json",
},
}
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("User signed up to stream");
// Verify Stream Chat upsertUser was called
expect(mockUpsertUser).toHaveBeenCalledTimes(1);
expect(mockUpsertUser).toHaveBeenCalledWith({
id: temporaryUser.userId,
name: expect.any(String),
language: "fr",
});
});
it("should deny unauthenticated stream signup", async () => {
const res = await client.users["sign-up-to-stream"].$post({});
expect(res.status).toBe(401);
expect(mockUpsertUser).not.toHaveBeenCalled();
});
});
describe("POST /profile/avatar - Upload Avatar", () => {
it("should upload avatar for owner user", async () => {
const res = await client.users.profile.avatar.$post(

View file

@ -8,8 +8,6 @@ export interface AppConfig {
SUPABASE_SERVICE_ROLE_KEY: string;
SUPABASE_CONNECTION_STRING: string;
SUPABASE_CA_CERT: string;
STREAM_CHAT_API_KEY: string;
STREAM_CHAT_API_SECRET: string;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
STRIPE_SOLO_PRICE_ID: string;
@ -59,8 +57,6 @@ export function createConfig(secrets?: Secrets): AppConfig {
const isTestMode = NODE_ENV === "test";
const isStagingMode = NODE_ENV === "staging";
const getStreamChatApiSecret = (isStagingMode: boolean) =>
isStagingMode ? secrets!.streamChatApiSecretStaging : secrets!.streamChatApiSecret;
const getStripeSecretKey = (isStagingMode: boolean) =>
isStagingMode ? secrets!.stripeSecretKeyStaging : secrets!.stripeSecretKey;
const getStripeWebhookSecret = (isStagingMode: boolean) =>
@ -82,11 +78,6 @@ export function createConfig(secrets?: Secrets): AppConfig {
SUPABASE_CA_CERT: isTestMode
? validateEnvVar("SUPABASE_CA_CERT", process.env.SUPABASE_CA_CERT)
: secrets!.supabaseCaCert,
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
// Env dependent
STREAM_CHAT_API_SECRET: isTestMode
? validateEnvVar("STREAM_CHAT_API_SECRET", process.env.STREAM_CHAT_API_SECRET)
: getStreamChatApiSecret(isStagingMode),
STRIPE_SECRET_KEY: isTestMode
? validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY)
: getStripeSecretKeyFromEnv() || getStripeSecretKey(isStagingMode),

View file

@ -3,7 +3,6 @@ import type { SupabaseClient } from "@supabase/supabase-js";
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 = 10;
@ -290,7 +289,6 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => {
*/
export const createInvitedUser = async (
supabase: SupabaseClient,
streamServerClient: StreamChat,
transporter: Transporter,
recipientEmail: string,
senderEmail: string,
@ -334,12 +332,6 @@ export const createInvitedUser = async (
return { success: false, error: updateProfileError.message };
}
await streamServerClient.upsertUser({
id: newUser.user.id,
name: recipientEmail.split("@")[0],
language: "fr",
});
// Send welcome email to the new user
await transporter.sendMail({
from: `${senderEmail} via XTablo <noreply@xtablo.com>`,

View file

@ -4,7 +4,6 @@ import { createClient, type SupabaseClient, type User } from "@supabase/supabase
import type { Context, MiddlewareHandler, Next } from "hono";
import { createMiddleware } from "hono/factory";
import type { Transporter } from "nodemailer";
import { StreamChat } from "stream-chat";
import { Stripe } from "stripe";
import { type AppConfig } from "../config.js";
import { authenticateFromHeader } from "../helpers/auth.js";
@ -25,9 +24,6 @@ export type Middlewares = {
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>;
streamChatMiddleware: MiddlewareHandler<{
Variables: { streamServerClient: StreamChat };
}>;
r2Middleware: MiddlewareHandler<{
Variables: { s3_client: S3Client };
}>;
@ -168,15 +164,6 @@ export class MiddlewareManager {
await next();
});
const streamChatMiddleware = createMiddleware(async (c: Context, next: Next) => {
const serverClient = StreamChat.getInstance(
config.STREAM_CHAT_API_KEY,
config.STREAM_CHAT_API_SECRET
);
c.set("streamServerClient", serverClient);
await next();
});
const r2Middleware = createMiddleware(async (c: Context, next: Next) => {
const s3 = new S3Client({
region: "auto",
@ -255,7 +242,6 @@ export class MiddlewareManager {
basicAuthMiddleware,
authMiddleware,
maybeAuthenticatedMiddleware,
streamChatMiddleware,
r2Middleware,
regularUserCheckMiddleware,
billingCheckoutAccessMiddleware,
@ -282,10 +268,6 @@ export class MiddlewareManager {
return this.middlewares.maybeAuthenticatedMiddleware;
}
get streamChat() {
return this.middlewares.streamChatMiddleware;
}
get r2() {
return this.middlewares.r2Middleware;
}

View file

@ -17,7 +17,6 @@ export const getMainRouter = (config: AppConfig) => {
mainRouter.use(middlewareManager.supabase);
// Apply remaining middlewares after public routes
mainRouter.use(middlewareManager.streamChat);
mainRouter.use(middlewareManager.r2);
mainRouter.use(middlewareManager.transporter);
mainRouter.use(middlewareManager.stripe);

View file

@ -9,7 +9,6 @@ const factory = createFactory<MaybeAuthEnv>();
const bookSlot = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const transporter = c.get("transporter");
const maybeUser = c.get("user");
@ -55,7 +54,6 @@ const bookSlot = factory.createHandlers(async (c) => {
// Create a temporary user for the booking
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
data.user_details.email,
ownerData.email,
@ -220,28 +218,6 @@ const bookSlot = factory.createHandlers(async (c) => {
return c.json({ error: tabloAccessError.message }, 500);
}
// 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: channelMembers,
});
await channel.create();
const newEvent: TablesInsert<"events"> = {
description: eventTypeConfig.description || "",
end_time: data.event_details.end_time || "",
@ -258,12 +234,6 @@ const bookSlot = factory.createHandlers(async (c) => {
return c.json({ error: "Failed to create event" }, 500);
}
// Send a welcome message to the channel
await channel.sendMessage({
text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${newEvent.title}" est confirmé pour le ${newEvent.start_date} de ${newEvent.start_time} à ${newEvent.end_time}.`,
user_id: ownerId,
});
// Send email notifications to both owner and invited user
// Send email to the owner
await transporter.sendMail({

View file

@ -18,83 +18,6 @@ type PostTablo = Omit<TabloInsert, "owner_id" | "organization_id"> & {
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,
@ -134,28 +57,6 @@ 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: channelMembers,
});
await channel.create();
if (typedPayload.events) {
const eventsToInsert = typedPayload.events.map((event) => ({
...event,
@ -173,7 +74,6 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id, ...tablo } = data;
@ -190,7 +90,7 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
return c.json({ error: "You are not authorized to update this tablo" }, 403);
}
const { data: update, error } = await supabase
const { error } = await supabase
.from("tablos")
.update(tablo)
.eq("id", id)
@ -201,28 +101,12 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
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 {
await channel.update({
// @ts-ignore
name: updatedTablo.name,
});
} catch (error) {
console.error("error updating channel", error);
}
}
return c.json({ message: "Tablo updated successfully" });
});
const deleteTablo = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id } = data;
@ -270,13 +154,6 @@ const deleteTablo = factory.createHandlers(async (c) => {
return c.json({ error: error.message }, 500);
}
const channel = streamServerClient.channel("messaging", id);
try {
await channel.delete();
} catch (error) {
console.error("error deleting channel", error);
}
return c.json({ message: "Tablo deleted successfully" });
});
@ -288,7 +165,6 @@ const inviteToTablo = (
const transporter = c.get("transporter");
const sender = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const tabloId = c.req.param("tabloId");
const { email: recipientmail } = await c.req.json();
@ -355,7 +231,6 @@ const inviteToTablo = (
// Create a new invited user and add them to the tablo
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
recipientEmail,
sender.email,
@ -381,13 +256,6 @@ 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",
});
@ -438,7 +306,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType<typeof MiddlewareMana
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const tabloId = c.req.param("tabloId");
const inviteId = Number(c.req.param("inviteId"));
@ -513,13 +380,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType<typeof MiddlewareMana
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" });
@ -573,7 +433,6 @@ const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const inviteId = Number(c.req.param("inviteId"));
if (!Number.isInteger(inviteId) || inviteId <= 0) {
@ -598,13 +457,6 @@ const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager
return c.json({ error: "You are not authorized to accept this invite" }, 403);
}
try {
await upsertStreamUserFromProfile(supabase, streamServerClient, user.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: inviteData.tablo_id,
user_id: user.id,
@ -621,13 +473,6 @@ const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", inviteData.id);
try {
await ensureTabloChannelMember(supabase, streamServerClient, inviteData.tablo_id, user.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: inviteData.tablo_id });
});
@ -636,7 +481,6 @@ const joinTablo = factory.createHandlers(async (c) => {
const joiner = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data: inviteData, error } = await supabase
.from("tablo_invites")
@ -657,13 +501,6 @@ 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,
@ -686,13 +523,6 @@ const joinTablo = factory.createHandlers(async (c) => {
// Mark invite as accepted instead of deleting (maintains audit trail)
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id);
try {
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 });
});
@ -748,12 +578,8 @@ const getTabloMembers = factory.createHandlers(async (c) => {
const leaveTablo = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { tablo_id } = await c.req.json();
const channel = streamServerClient.channel("messaging", tablo_id);
await channel.removeMembers([user.id]);
const { error } = await supabase
.from("tablo_access")
.update({ is_active: false })
@ -872,7 +698,6 @@ export const getTabloRouter = (config: AppConfig) => {
tabloRouter.use(middlewareManager.supabase);
tabloRouter.use(middlewareManager.auth);
tabloRouter.use(middlewareManager.streamChat);
tabloRouter.post("/create", ...createTablo(middlewareManager));
tabloRouter.patch("/update", ...updateTablo(middlewareManager));

View file

@ -39,7 +39,6 @@ const syncCalendars = factory.createHandlers(async (c) => {
const syncTabloNames = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const fifteenMinutesInMilliseconds = 1000 * 60 * 15;
@ -54,18 +53,6 @@ const syncTabloNames = factory.createHandlers(async (c) => {
const tablosData = data as { id: string; name: string }[];
tablosData.forEach(async (tablo) => {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.update({
// @ts-ignore
name: tablo.name,
});
} catch (error) {
console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`);
}
});
return c.json({ message: `Synced ${tablosData.length} tablo names` });
});

View file

@ -11,30 +11,9 @@ const factory = createFactory<AuthEnv>();
const isMissingRelationError = (code: string | undefined) =>
code === "42P01" || code === "PGRST205";
const signUpToStream = factory.createHandlers(async (c) => {
const { id } = c.get("user");
const supabase = c.get("supabase");
const { data } = await supabase.from("profiles").select("*").eq("id", id).single();
const user = data as Tables<"profiles">;
const streamServerClient = c.get("streamServerClient");
await streamServerClient.upsertUser({
id,
name: user.name ?? "",
language: "fr",
});
return c.json({
message: "User signed up to stream",
});
});
const getMe = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
@ -60,14 +39,7 @@ const getMe = factory.createHandlers(async (c) => {
effectivePlan = organizationPlan;
}
const user_id = data.id;
const token = streamServerClient.createToken(user_id);
return c.json({
...userData,
plan: effectivePlan,
streamToken: token,
});
return c.json({ ...userData, plan: effectivePlan });
});
const markTemporary = factory.createHandlers(async (c) => {
@ -515,7 +487,6 @@ 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();
@ -613,7 +584,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
const invitedUser = await createInvitedUser(
supabase,
streamServerClient,
transporter,
recipientEmail,
senderProfile.email,
@ -673,15 +643,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
}
}
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")
@ -717,7 +678,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
const removeOrganizationMember = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const memberId = c.req.param("memberId");
if (!memberId) {
@ -826,14 +786,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => {
return c.json({ error: "Failed to revoke member tablo permissions" }, 500);
}
for (const tabloId of tabloIds) {
try {
const channel = streamServerClient.channel("messaging", tabloId);
await channel.removeMembers([memberId]);
} catch (error) {
console.error("Failed to remove organization member from Stream channel:", error);
}
}
}
const { error: inviteCleanupError } = await supabase
@ -852,7 +804,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => {
export const getUserRouter = () => {
const userRouter = new Hono();
userRouter.post("/sign-up-to-stream", ...signUpToStream);
userRouter.get("/me", ...getMe);
userRouter.post("/mark-temporary", ...markTemporary);
userRouter.post("/profile/avatar", ...uploadAvatar);

View file

@ -26,11 +26,9 @@ export type Secrets = {
r2AccessKeyId: string;
r2SecretAccessKey: string;
// Env dependent
streamChatApiSecret: string;
stripeSecretKey: string;
stripeWebhookSecret: string;
// Staging
streamChatApiSecretStaging: string;
stripeSecretKeyStaging: string;
stripeWebhookSecretStaging: string;
};
@ -50,11 +48,9 @@ export async function loadSecrets(): Promise<Secrets> {
r2SecretAccessKey: await fetchSecret("r2-secret-access-key"),
// Env dependent
// Staging
streamChatApiSecretStaging: await fetchSecret("stream-chat-api-secret-staging"),
stripeSecretKeyStaging: await fetchSecret("stripe-secret-key-staging"),
stripeWebhookSecretStaging: await fetchSecret("stripe-webhook-secret-staging"),
// Production
streamChatApiSecret: await fetchSecret("stream-chat-api-secret"),
stripeSecretKey: await fetchSecret("stripe-secret-key"),
stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"),
};

View file

@ -3,7 +3,6 @@ import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Hono } from "hono";
import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import type Stripe from "stripe";
/**
@ -12,7 +11,6 @@ import type Stripe from "stripe";
export type BaseEnv = {
Variables: {
supabase: SupabaseClient;
streamServerClient: StreamChat;
s3_client: S3Client;
transporter: Transporter;
stripe: Stripe;