mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(subscriptions): fixed organization creation failure introduced by subscription updates (#361)
* fix(subscriptions): fixed organization creation failure introduced by subscription updates * cleaned up tests * run format
This commit is contained in:
@@ -34,4 +34,7 @@ coverage
|
||||
**/public/sw.js
|
||||
**/public/workbox-*.js
|
||||
**/public/worker-*.js
|
||||
**/public/fallback-*.js
|
||||
**/public/fallback-*.js
|
||||
|
||||
# Documentation
|
||||
apps/docs/**/*.mdx
|
||||
@@ -45,4 +45,4 @@
|
||||
"x",
|
||||
"youtube"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,26 +57,26 @@ export function useVerification({
|
||||
if (storedEmail) {
|
||||
setEmail(storedEmail)
|
||||
}
|
||||
|
||||
|
||||
// Check for redirect information
|
||||
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
|
||||
if (storedRedirectUrl) {
|
||||
setRedirectUrl(storedRedirectUrl)
|
||||
}
|
||||
|
||||
|
||||
// Check if this is an invite flow
|
||||
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
|
||||
if (storedIsInviteFlow === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also check URL parameters for redirect information
|
||||
const redirectParam = searchParams.get('redirectAfter')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
}
|
||||
|
||||
|
||||
// Check for invite_flow parameter
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
@@ -130,7 +130,7 @@ export function useVerification({
|
||||
// Clear email from sessionStorage after successful verification
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
|
||||
|
||||
// Also clear invite-related items
|
||||
if (isInviteFlow) {
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { vi } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
/**
|
||||
* Mock sample workflow state for testing
|
||||
*/
|
||||
export const sampleWorkflowState = {
|
||||
blocks: {
|
||||
'starter-id': {
|
||||
@@ -65,51 +62,108 @@ export const sampleWorkflowState = {
|
||||
isDeployed: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock database with test data
|
||||
*/
|
||||
export function mockDb() {
|
||||
return {
|
||||
select: vi.fn().mockImplementation(() => ({
|
||||
from: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockImplementation(() => ({
|
||||
limit: vi.fn().mockImplementation(() => [
|
||||
{
|
||||
id: 'workflow-id',
|
||||
userId: 'user-id',
|
||||
state: sampleWorkflowState,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
export const mockDb = {
|
||||
select: vi.fn().mockImplementation(() => ({
|
||||
from: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockImplementation(() => ({
|
||||
limit: vi.fn().mockImplementation(() => [
|
||||
{
|
||||
id: 'workflow-id',
|
||||
userId: 'user-id',
|
||||
state: sampleWorkflowState,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn().mockImplementation(() => ({
|
||||
set: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn().mockImplementation(() => ({
|
||||
set: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}
|
||||
})),
|
||||
eq: vi.fn().mockImplementation((field, value) => ({ field, value, type: 'eq' })),
|
||||
and: vi.fn().mockImplementation((...conditions) => ({
|
||||
conditions,
|
||||
type: 'and',
|
||||
})),
|
||||
}
|
||||
|
||||
export const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
|
||||
export const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
}
|
||||
|
||||
export const mockSubscription = {
|
||||
id: 'sub-123',
|
||||
plan: 'enterprise',
|
||||
status: 'active',
|
||||
seats: 5,
|
||||
referenceId: 'user-123',
|
||||
metadata: {
|
||||
perSeatAllowance: 100,
|
||||
totalAllowance: 500,
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
},
|
||||
}
|
||||
|
||||
export const mockOrganization = {
|
||||
id: 'org-456',
|
||||
name: 'Test Organization',
|
||||
slug: 'test-org',
|
||||
}
|
||||
|
||||
export const mockAdminMember = {
|
||||
id: 'member-123',
|
||||
userId: 'user-123',
|
||||
organizationId: 'org-456',
|
||||
role: 'admin',
|
||||
}
|
||||
|
||||
export const mockRegularMember = {
|
||||
id: 'member-456',
|
||||
userId: 'user-123',
|
||||
organizationId: 'org-456',
|
||||
role: 'member',
|
||||
}
|
||||
|
||||
export const mockTeamSubscription = {
|
||||
id: 'sub-456',
|
||||
plan: 'team',
|
||||
status: 'active',
|
||||
seats: 5,
|
||||
referenceId: 'org-123',
|
||||
}
|
||||
|
||||
export const mockPersonalSubscription = {
|
||||
id: 'sub-789',
|
||||
plan: 'enterprise',
|
||||
status: 'active',
|
||||
seats: 5,
|
||||
referenceId: 'user-123',
|
||||
metadata: {
|
||||
perSeatAllowance: 100,
|
||||
totalAllowance: 500,
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock environment variables for testing
|
||||
*/
|
||||
export const mockEnvironmentVars = {
|
||||
OPENAI_API_KEY: 'encrypted:openai-api-key',
|
||||
SERPER_API_KEY: 'encrypted:serper-api-key',
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock decrypted environment variables for testing
|
||||
*/
|
||||
export const mockDecryptedEnvVars = {
|
||||
OPENAI_API_KEY: 'sk-test123',
|
||||
SERPER_API_KEY: 'serper-test123',
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock Next.js request for testing
|
||||
*/
|
||||
export function createMockRequest(
|
||||
method: string = 'GET',
|
||||
body?: any,
|
||||
@@ -125,11 +179,7 @@ export function createMockRequest(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the executeWorkflow function dependencies
|
||||
*/
|
||||
export function mockExecutionDependencies() {
|
||||
// Mock decryptSecret function
|
||||
vi.mock('@/lib/utils', async () => {
|
||||
const actual = await vi.importActual('@/lib/utils')
|
||||
return {
|
||||
@@ -150,13 +200,11 @@ export function mockExecutionDependencies() {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock execution logger functions
|
||||
vi.mock('@/lib/logs/execution-logger', () => ({
|
||||
persistExecutionLogs: vi.fn().mockResolvedValue(undefined),
|
||||
persistExecutionError: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
// Mock trace spans builder
|
||||
vi.mock('@/lib/logs/trace-spans', () => ({
|
||||
buildTraceSpans: vi.fn().mockReturnValue({
|
||||
traceSpans: [],
|
||||
@@ -164,12 +212,10 @@ export function mockExecutionDependencies() {
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow utils
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
updateWorkflowRunCounts: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
// Mock serializer
|
||||
vi.mock('@/serializer', () => ({
|
||||
Serializer: vi.fn().mockImplementation(() => ({
|
||||
serializeWorkflow: vi.fn().mockReturnValue({
|
||||
@@ -205,7 +251,6 @@ export function mockExecutionDependencies() {
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock executor
|
||||
vi.mock('@/executor', () => ({
|
||||
Executor: vi.fn().mockImplementation(() => ({
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
@@ -226,15 +271,11 @@ export function mockExecutionDependencies() {
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock database
|
||||
vi.mock('@/db', () => ({
|
||||
db: mockDb(),
|
||||
db: mockDb,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the workflow access validation middleware
|
||||
*/
|
||||
export function mockWorkflowAccessValidation(shouldSucceed = true) {
|
||||
if (shouldSucceed) {
|
||||
vi.mock('@/app/api/workflows/middleware', () => ({
|
||||
@@ -258,11 +299,7 @@ export function mockWorkflowAccessValidation(shouldSucceed = true) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mocked dependencies for validation
|
||||
*/
|
||||
export async function getMockedDependencies() {
|
||||
// Using dynamic imports to avoid module resolution issues
|
||||
const utilsModule = await import('@/lib/utils')
|
||||
const logsModule = await import('@/lib/logs/execution-logger')
|
||||
const traceSpansModule = await import('@/lib/logs/trace-spans')
|
||||
|
||||
251
apps/sim/app/api/user/subscription/[id]/seats/route.test.ts
Normal file
251
apps/sim/app/api/user/subscription/[id]/seats/route.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Tests for Subscription Seats Update API
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
mockDb,
|
||||
mockLogger,
|
||||
mockPersonalSubscription,
|
||||
mockRegularMember,
|
||||
mockSubscription,
|
||||
mockTeamSubscription,
|
||||
mockUser,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Subscription Seats Update API Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: mockUser,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/db', () => ({
|
||||
db: mockDb,
|
||||
}))
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
})
|
||||
|
||||
const mockSetFn = vi.fn().mockReturnThis()
|
||||
const mockWhereFn = vi.fn().mockResolvedValue([{ affected: 1 }])
|
||||
mockDb.update.mockReturnValue({
|
||||
set: mockSetFn,
|
||||
where: mockWhereFn,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('POST handler', () => {
|
||||
it('should encounter a permission error when trying to update subscription seats', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
mockDb.select.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
}))
|
||||
|
||||
mockDb.select.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty(
|
||||
'error',
|
||||
'Unauthorized - you do not have permission to modify this subscription'
|
||||
)
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject team plan subscription updates', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockTeamSubscription]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty(
|
||||
'error',
|
||||
'Only enterprise subscriptions can be updated through this endpoint'
|
||||
)
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should encounter permission issues with personal subscription updates', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockPersonalSubscription]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should reject updates from non-admin members', async () => {
|
||||
vi.doMock('@/lib/subscription/utils', () => ({
|
||||
checkEnterprisePlan: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
const mockSelectImpl = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockRegularMember]),
|
||||
})
|
||||
|
||||
mockDb.select.mockImplementation(mockSelectImpl)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should reject invalid request parameters', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
seats: -5,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'Invalid request parameters')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle subscription not found with permission error', async () => {
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle authentication error', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle internal server error', async () => {
|
||||
mockDb.select.mockImplementation(() => {
|
||||
throw new Error('Database error')
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
seats: 10,
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toHaveProperty('error', 'Failed to update subscription seats')
|
||||
expect(mockLogger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
@@ -7,11 +7,10 @@ import { checkEnterprisePlan } from '@/lib/subscription/utils'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UpdateSubscriptionSeatsAPI')
|
||||
const logger = createLogger('SubscriptionSeatsUpdateAPI')
|
||||
|
||||
const updateSeatsSchema = z.object({
|
||||
subscriptionId: z.string().uuid(),
|
||||
seats: z.number().int().positive(),
|
||||
seats: z.number().int().min(1),
|
||||
})
|
||||
|
||||
const subscriptionMetadataSchema = z
|
||||
@@ -29,17 +28,29 @@ interface SubscriptionMetadata {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const subscriptionId = (await params).id
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
logger.warn('Unauthorized seats update attempt')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const rawBody = await req.json()
|
||||
const validationResult = updateSeatsSchema.safeParse(rawBody)
|
||||
let body
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch (parseError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid JSON in request body',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const validationResult = updateSeatsSchema.safeParse(body)
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -50,58 +61,44 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const { subscriptionId, seats } = validationResult.data
|
||||
const { seats } = validationResult.data
|
||||
|
||||
const subscriptions = await db
|
||||
const sub = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
if (!sub) {
|
||||
return NextResponse.json({ error: 'Subscription not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const sub = subscriptions[0]
|
||||
|
||||
if (!checkEnterprisePlan(sub)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Only enterprise subscriptions can be updated through this endpoint',
|
||||
},
|
||||
{ error: 'Only enterprise subscriptions can be updated through this endpoint' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let hasPermission = sub.referenceId === session.user.id
|
||||
const isPersonalSubscription = sub.referenceId === session.user.id
|
||||
|
||||
if (!hasPermission) {
|
||||
const memberships = await db
|
||||
let hasAccess = isPersonalSubscription
|
||||
|
||||
if (!isPersonalSubscription) {
|
||||
const mem = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, session.user.id),
|
||||
eq(member.organizationId, sub.referenceId),
|
||||
or(eq(member.role, 'owner'), eq(member.role, 'admin'))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, sub.referenceId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
hasPermission = memberships.length > 0
|
||||
hasAccess = mem && (mem.role === 'owner' || mem.role === 'admin')
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn('Unauthorized subscription update attempt', {
|
||||
userId: session.user.id,
|
||||
subscriptionId,
|
||||
referenceId: sub.referenceId,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'You must be an admin or owner to update subscription settings' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - you do not have permission to modify this subscription' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
let validatedMetadata: SubscriptionMetadata
|
||||
@@ -132,30 +129,23 @@ export async function POST(req: Request) {
|
||||
})
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
|
||||
logger.info('Updated subscription seats', {
|
||||
logger.info('Subscription seats updated', {
|
||||
subscriptionId,
|
||||
previousSeats: sub.seats,
|
||||
oldSeats: sub.seats,
|
||||
newSeats: seats,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Subscription seats updated',
|
||||
data: {
|
||||
subscriptionId,
|
||||
seats,
|
||||
plan: sub.plan,
|
||||
metadata: validatedMetadata,
|
||||
},
|
||||
message: 'Subscription seats updated successfully',
|
||||
seats,
|
||||
metadata: validatedMetadata,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription seats:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to update subscription seats',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
logger.error('Error updating subscription seats', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to update subscription seats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
278
apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts
Normal file
278
apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Tests for Subscription Transfer API
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
mockAdminMember,
|
||||
mockDb,
|
||||
mockLogger,
|
||||
mockOrganization,
|
||||
mockRegularMember,
|
||||
mockSubscription,
|
||||
mockUser,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Subscription Transfer API Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: mockUser,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/db', () => ({
|
||||
db: mockDb,
|
||||
}))
|
||||
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
})
|
||||
|
||||
mockDb.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([{ affected: 1 }]),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('POST handler', () => {
|
||||
it('should successfully transfer a personal subscription to an organization', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: {
|
||||
...mockUser,
|
||||
id: 'user-123',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
subscription: { id: 'id', referenceId: 'referenceId' },
|
||||
organization: { id: 'id' },
|
||||
member: { userId: 'userId', organizationId: 'organizationId', role: 'role' },
|
||||
}))
|
||||
|
||||
const mockSubscriptionWithReferenceId = {
|
||||
...mockSubscription,
|
||||
referenceId: 'user-123',
|
||||
}
|
||||
|
||||
mockDb.select.mockImplementation(() => {
|
||||
return {
|
||||
from: () => ({
|
||||
where: () => {
|
||||
if (mockDb.select.mock.calls.length === 1) {
|
||||
return Promise.resolve([mockSubscriptionWithReferenceId])
|
||||
} else if (mockDb.select.mock.calls.length === 2) {
|
||||
return Promise.resolve([mockOrganization])
|
||||
} else {
|
||||
return Promise.resolve([mockAdminMember])
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
mockDb.update.mockReturnValue({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve({ affected: 1 }),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
organizationId: 'org-456',
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(data).toHaveProperty('message', 'Subscription transferred successfully')
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should test behavior when subscription not found', async () => {
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
organizationId: 'org-456',
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
|
||||
})
|
||||
|
||||
it('should test behavior when organization not found', async () => {
|
||||
const mockSelectImpl = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockSubscription]),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([]),
|
||||
})
|
||||
|
||||
mockDb.select.mockImplementation(mockSelectImpl)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
organizationId: 'org-456',
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
|
||||
})
|
||||
|
||||
it('should reject transfer if user is not the subscription owner', async () => {
|
||||
const differentOwnerSubscription = {
|
||||
...mockSubscription,
|
||||
referenceId: 'different-user-123',
|
||||
}
|
||||
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([differentOwnerSubscription]),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
organizationId: 'org-456',
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject non-personal transfer if user is not admin of organization', async () => {
|
||||
const orgOwnedSubscription = {
|
||||
...mockSubscription,
|
||||
referenceId: 'other-org-789',
|
||||
}
|
||||
|
||||
const mockSelectImpl = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([orgOwnedSubscription]),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockOrganization]),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockResolvedValue([mockRegularMember]),
|
||||
})
|
||||
|
||||
mockDb.select.mockImplementation(mockSelectImpl)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
organizationId: 'org-456',
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject invalid request parameters', async () => {
|
||||
const req = createMockRequest('POST', {})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error', 'Invalid request parameters')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle authentication error', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
organizationId: 'org-456',
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle internal server error', async () => {
|
||||
mockDb.select.mockImplementation(() => {
|
||||
throw new Error('Database error')
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
organizationId: 'org-456',
|
||||
})
|
||||
|
||||
const { POST } = await import('./route')
|
||||
|
||||
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toHaveProperty('error', 'Failed to transfer subscription')
|
||||
expect(mockLogger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
114
apps/sim/app/api/user/subscription/[id]/transfer/route.ts
Normal file
114
apps/sim/app/api/user/subscription/[id]/transfer/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, subscription } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('SubscriptionTransferAPI')
|
||||
|
||||
const transferSubscriptionSchema = z.object({
|
||||
organizationId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const subscriptionId = (await params).id
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized subscription transfer attempt')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch (parseError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid JSON in request body',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const validationResult = transferSubscriptionSchema.safeParse(body)
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid request parameters',
|
||||
details: validationResult.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { organizationId } = validationResult.data
|
||||
logger.info('Processing subscription transfer', { subscriptionId, organizationId })
|
||||
|
||||
const sub = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sub) {
|
||||
return NextResponse.json({ error: 'Subscription not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (sub.referenceId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - subscription does not belong to user' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const org = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const mem = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
const isPersonalTransfer = sub.referenceId === session.user.id
|
||||
|
||||
if (!isPersonalTransfer && (!mem || (mem.role !== 'owner' && mem.role !== 'admin'))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - user is not admin of organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ referenceId: organizationId })
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
|
||||
logger.info('Subscription transfer completed', {
|
||||
subscriptionId,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Subscription transferred successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error transferring subscription', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TransferSubscriptionAPI')
|
||||
|
||||
const transferSubscriptionSchema = z.object({
|
||||
subscriptionId: z.string().uuid(),
|
||||
organizationId: z.string().uuid(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized subscription transfer attempt')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validationResult = transferSubscriptionSchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid request parameters',
|
||||
details: validationResult.error.format(),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { subscriptionId, organizationId } = validationResult.data
|
||||
|
||||
logger.info('Transferring subscription to organization', {
|
||||
userId: session.user.id,
|
||||
subscriptionId,
|
||||
organizationId,
|
||||
})
|
||||
|
||||
const subscription = await db
|
||||
.select()
|
||||
.from(schema.subscription)
|
||||
.where(eq(schema.subscription.id, subscriptionId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('Subscription not found', { subscriptionId })
|
||||
return NextResponse.json({ error: 'Subscription not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (subscription.referenceId !== session.user.id) {
|
||||
logger.warn('Unauthorized subscription transfer - subscription does not belong to user', {
|
||||
userId: session.user.id,
|
||||
subscriptionReferenceId: subscription.referenceId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - subscription does not belong to user' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const organization = await db
|
||||
.select()
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.id, organizationId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!organization) {
|
||||
logger.warn('Organization not found', { organizationId })
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const member = await db
|
||||
.select()
|
||||
.from(schema.member)
|
||||
.where(
|
||||
eq(schema.member.userId, session.user.id) &&
|
||||
eq(schema.member.organizationId, organizationId)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!member || (member.role !== 'owner' && member.role !== 'admin')) {
|
||||
logger.warn('Unauthorized subscription transfer - user is not admin of organization', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
memberRole: member?.role,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - user is not admin of organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.subscription)
|
||||
.set({ referenceId: organizationId })
|
||||
.where(eq(schema.subscription.id, subscriptionId))
|
||||
|
||||
logger.info('Successfully transferred subscription to organization', {
|
||||
subscriptionId,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Subscription transferred successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error transferring subscription', { error })
|
||||
return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -184,13 +184,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// --- Gmail webhook setup ---
|
||||
if (savedWebhook && provider === 'gmail') {
|
||||
logger.info(
|
||||
`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`
|
||||
)
|
||||
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
||||
try {
|
||||
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
|
||||
const success = await configureGmailPolling(userId, savedWebhook, requestId)
|
||||
|
||||
|
||||
if (!success) {
|
||||
logger.error(`[${requestId}] Failed to configure Gmail polling`)
|
||||
return NextResponse.json(
|
||||
@@ -201,7 +199,7 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
logger.info(`[${requestId}] Successfully configured Gmail polling`)
|
||||
} catch (err) {
|
||||
logger.error(`[${requestId}] Error setting up Gmail webhook configuration`, err)
|
||||
@@ -390,7 +388,7 @@ async function createTelegramWebhookSubscription(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'TelegramBot/1.0'
|
||||
'User-Agent': 'TelegramBot/1.0',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
@@ -409,28 +407,28 @@ async function createTelegramWebhookSubscription(
|
||||
logger.info(
|
||||
`[${requestId}] Successfully created Telegram webhook for webhook ${webhookData.id}.`
|
||||
)
|
||||
|
||||
|
||||
// Get webhook info to ensure it's properly set up
|
||||
try {
|
||||
const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo`
|
||||
const webhookInfo = await fetch(webhookInfoUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'TelegramBot/1.0'
|
||||
}
|
||||
});
|
||||
const webhookInfoJson = await webhookInfo.json();
|
||||
|
||||
'User-Agent': 'TelegramBot/1.0',
|
||||
},
|
||||
})
|
||||
const webhookInfoJson = await webhookInfo.json()
|
||||
|
||||
if (webhookInfoJson.ok) {
|
||||
logger.info(`[${requestId}] Telegram webhook info:`, {
|
||||
url: webhookInfoJson.result.url,
|
||||
has_custom_certificate: webhookInfoJson.result.has_custom_certificate,
|
||||
pending_update_count: webhookInfoJson.result.pending_update_count,
|
||||
webhookId: webhookData.id
|
||||
});
|
||||
webhookId: webhookData.id,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-critical error, just log
|
||||
logger.warn(`[${requestId}] Failed to get webhook info`, error);
|
||||
logger.warn(`[${requestId}] Failed to get webhook info`, error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
|
||||
@@ -146,23 +146,23 @@ export async function GET(request: NextRequest) {
|
||||
message_id: 67890,
|
||||
from: {
|
||||
id: 123456789,
|
||||
first_name: "Test",
|
||||
username: "testbot"
|
||||
first_name: 'Test',
|
||||
username: 'testbot',
|
||||
},
|
||||
chat: {
|
||||
id: 123456789,
|
||||
first_name: "Test",
|
||||
username: "testbot",
|
||||
type: "private"
|
||||
first_name: 'Test',
|
||||
username: 'testbot',
|
||||
type: 'private',
|
||||
},
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
text: "This is a test message"
|
||||
}
|
||||
text: 'This is a test message',
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Testing Telegram webhook connection`, {
|
||||
webhookId,
|
||||
url: webhookUrl
|
||||
url: webhookUrl,
|
||||
})
|
||||
|
||||
// Make a test request to the webhook endpoint
|
||||
@@ -170,16 +170,16 @@ export async function GET(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'TelegramBot/1.0'
|
||||
'User-Agent': 'TelegramBot/1.0',
|
||||
},
|
||||
body: JSON.stringify(testMessage)
|
||||
body: JSON.stringify(testMessage),
|
||||
})
|
||||
|
||||
// Get the response details
|
||||
const status = response.status
|
||||
let responseText = '';
|
||||
let responseText = ''
|
||||
try {
|
||||
responseText = await response.text();
|
||||
responseText = await response.text()
|
||||
} catch (e) {
|
||||
// Ignore if we can't get response text
|
||||
}
|
||||
@@ -192,7 +192,7 @@ export async function GET(request: NextRequest) {
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Telegram webhook test failed: ${webhookId}`, {
|
||||
status,
|
||||
responseText
|
||||
responseText,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,8 +202,8 @@ export async function GET(request: NextRequest) {
|
||||
const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo`
|
||||
const infoResponse = await fetch(webhookInfoUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'TelegramBot/1.0'
|
||||
}
|
||||
'User-Agent': 'TelegramBot/1.0',
|
||||
},
|
||||
})
|
||||
if (infoResponse.ok) {
|
||||
const infoJson = await infoResponse.json()
|
||||
@@ -220,7 +220,7 @@ export async function GET(request: NextRequest) {
|
||||
`curl -X POST "${webhookUrl}"`,
|
||||
`-H "Content-Type: application/json"`,
|
||||
`-H "User-Agent: TelegramBot/1.0"`,
|
||||
`-d '${JSON.stringify(testMessage, null, 2)}'`
|
||||
`-d '${JSON.stringify(testMessage, null, 2)}'`,
|
||||
].join(' \\\n')
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -230,18 +230,18 @@ export async function GET(request: NextRequest) {
|
||||
url: webhookUrl,
|
||||
botToken: `${botToken.substring(0, 5)}...${botToken.substring(botToken.length - 5)}`, // Show partial token for security
|
||||
triggerPhrase,
|
||||
isActive: foundWebhook.isActive
|
||||
isActive: foundWebhook.isActive,
|
||||
},
|
||||
test: {
|
||||
status,
|
||||
responseText,
|
||||
webhookInfo
|
||||
webhookInfo,
|
||||
},
|
||||
message: success
|
||||
? 'Telegram webhook appears to be working. Your bot should now receive messages.'
|
||||
: 'Telegram webhook test failed. Please check server logs for more details.',
|
||||
curlCommand,
|
||||
info: "To fix issues with Telegram webhooks getting 403 Forbidden responses, ensure the webhook request includes a User-Agent header."
|
||||
info: 'To fix issues with Telegram webhooks getting 403 Forbidden responses, ensure the webhook request includes a User-Agent header.',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -196,16 +196,19 @@ export async function POST(
|
||||
logger.info(`[${requestId}] Received Telegram webhook request:`, {
|
||||
userAgent,
|
||||
path,
|
||||
clientIp: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown',
|
||||
clientIp:
|
||||
request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown',
|
||||
method: request.method,
|
||||
contentType: request.headers.get('content-type'),
|
||||
hasUpdate: !!body?.update_id
|
||||
hasUpdate: !!body?.update_id,
|
||||
})
|
||||
|
||||
// Ensure User-Agent headers for Telegram in future requests from the bot
|
||||
// We can't modify the incoming request, but we can recommend adding it for future setup
|
||||
if (!userAgent || userAgent === 'empty') {
|
||||
logger.warn(`[${requestId}] Telegram webhook request missing User-Agent header. Recommend reconfiguring webhook with 'TelegramBot/1.0' User-Agent.`)
|
||||
logger.warn(
|
||||
`[${requestId}] Telegram webhook request missing User-Agent header. Recommend reconfiguring webhook with 'TelegramBot/1.0' User-Agent.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
validation.workflow.deployedState as any
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return createSuccessResponse({
|
||||
isDeployed: validation.workflow.isDeployed,
|
||||
deployedAt: validation.workflow.deployedAt,
|
||||
|
||||
@@ -87,14 +87,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
// Get existing variables to merge with the incoming ones
|
||||
const existingVariables = (workflowRecord[0].variables as Record<string, Variable>) || {}
|
||||
|
||||
|
||||
// Create a timestamp based on the current request
|
||||
|
||||
|
||||
// Merge variables: Keep existing ones and update/add new ones
|
||||
// This prevents variables from being deleted during race conditions
|
||||
const mergedVariables = {
|
||||
...existingVariables,
|
||||
...variablesRecord
|
||||
...variablesRecord,
|
||||
}
|
||||
|
||||
// Update workflow with variables
|
||||
|
||||
@@ -48,37 +48,40 @@ const SyncPayloadSchema = z.object({
|
||||
})
|
||||
|
||||
// Cache for workspace membership to reduce DB queries
|
||||
const workspaceMembershipCache = new Map<string, { role: string, expires: number }>();
|
||||
const CACHE_TTL = 60000; // 1 minute cache expiration
|
||||
const MAX_CACHE_SIZE = 1000; // Maximum number of entries to prevent unbounded growth
|
||||
const workspaceMembershipCache = new Map<string, { role: string; expires: number }>()
|
||||
const CACHE_TTL = 60000 // 1 minute cache expiration
|
||||
const MAX_CACHE_SIZE = 1000 // Maximum number of entries to prevent unbounded growth
|
||||
|
||||
/**
|
||||
* Cleans up expired entries from the workspace membership cache
|
||||
*/
|
||||
function cleanupExpiredCacheEntries(): void {
|
||||
const now = Date.now();
|
||||
let expiredCount = 0;
|
||||
|
||||
const now = Date.now()
|
||||
let expiredCount = 0
|
||||
|
||||
// Remove expired entries
|
||||
for (const [key, value] of workspaceMembershipCache.entries()) {
|
||||
if (value.expires <= now) {
|
||||
workspaceMembershipCache.delete(key);
|
||||
expiredCount++;
|
||||
workspaceMembershipCache.delete(key)
|
||||
expiredCount++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If we're still over the limit after removing expired entries,
|
||||
// remove the oldest entries (those that will expire soonest)
|
||||
if (workspaceMembershipCache.size > MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(workspaceMembershipCache.entries())
|
||||
.sort((a, b) => a[1].expires - b[1].expires);
|
||||
|
||||
const toRemove = entries.slice(0, workspaceMembershipCache.size - MAX_CACHE_SIZE);
|
||||
toRemove.forEach(([key]) => workspaceMembershipCache.delete(key));
|
||||
|
||||
logger.debug(`Cache cleanup: removed ${expiredCount} expired entries and ${toRemove.length} additional entries due to size limit`);
|
||||
const entries = Array.from(workspaceMembershipCache.entries()).sort(
|
||||
(a, b) => a[1].expires - b[1].expires
|
||||
)
|
||||
|
||||
const toRemove = entries.slice(0, workspaceMembershipCache.size - MAX_CACHE_SIZE)
|
||||
toRemove.forEach(([key]) => workspaceMembershipCache.delete(key))
|
||||
|
||||
logger.debug(
|
||||
`Cache cleanup: removed ${expiredCount} expired entries and ${toRemove.length} additional entries due to size limit`
|
||||
)
|
||||
} else if (expiredCount > 0) {
|
||||
logger.debug(`Cache cleanup: removed ${expiredCount} expired entries`);
|
||||
logger.debug(`Cache cleanup: removed ${expiredCount} expired entries`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,46 +91,46 @@ function cleanupExpiredCacheEntries(): void {
|
||||
* @param workspaceId Workspace ID to check
|
||||
* @returns Role if user is a member, null otherwise
|
||||
*/
|
||||
async function verifyWorkspaceMembership(userId: string, workspaceId: string): Promise<string | null> {
|
||||
async function verifyWorkspaceMembership(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<string | null> {
|
||||
// Opportunistic cleanup of expired cache entries
|
||||
if (workspaceMembershipCache.size > MAX_CACHE_SIZE / 2) {
|
||||
cleanupExpiredCacheEntries();
|
||||
cleanupExpiredCacheEntries()
|
||||
}
|
||||
|
||||
|
||||
// Create cache key from userId and workspaceId
|
||||
const cacheKey = `${userId}:${workspaceId}`;
|
||||
|
||||
const cacheKey = `${userId}:${workspaceId}`
|
||||
|
||||
// Check cache first
|
||||
const cached = workspaceMembershipCache.get(cacheKey);
|
||||
const cached = workspaceMembershipCache.get(cacheKey)
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.role;
|
||||
return cached.role
|
||||
}
|
||||
|
||||
|
||||
// If not in cache or expired, query the database
|
||||
try {
|
||||
const membership = await db
|
||||
.select({ role: workspaceMember.role })
|
||||
.from(workspaceMember)
|
||||
.where(and(
|
||||
eq(workspaceMember.workspaceId, workspaceId),
|
||||
eq(workspaceMember.userId, userId)
|
||||
))
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
.where(and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, userId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!membership) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
// Cache the result
|
||||
workspaceMembershipCache.set(cacheKey, {
|
||||
role: membership.role,
|
||||
expires: Date.now() + CACHE_TTL
|
||||
});
|
||||
|
||||
return membership.role;
|
||||
expires: Date.now() + CACHE_TTL,
|
||||
})
|
||||
|
||||
return membership.role
|
||||
} catch (error) {
|
||||
logger.error(`Error verifying workspace membership for ${userId} in ${workspaceId}:`, error);
|
||||
return null;
|
||||
logger.error(`Error verifying workspace membership for ${userId} in ${workspaceId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +171,7 @@ export async function GET(request: Request) {
|
||||
|
||||
// Verify the user is a member of the workspace using our optimized function
|
||||
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
|
||||
|
||||
|
||||
if (!userRole) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
|
||||
@@ -180,7 +183,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Migrate any orphaned workflows to this workspace (in background)
|
||||
migrateOrphanedWorkflows(userId, workspaceId).catch(error => {
|
||||
migrateOrphanedWorkflows(userId, workspaceId).catch((error) => {
|
||||
logger.error(`[${requestId}] Error migrating orphaned workflows:`, error)
|
||||
})
|
||||
}
|
||||
@@ -191,18 +194,17 @@ export async function GET(request: Request) {
|
||||
if (workspaceId) {
|
||||
// Filter by workspace ID only, not user ID
|
||||
// This allows sharing workflows across workspace members
|
||||
workflows = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
|
||||
} else {
|
||||
// Filter by user ID only, including workflows without workspace IDs
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Workflow fetch completed in ${elapsed}ms for ${workflows.length} workflows`)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Workflow fetch completed in ${elapsed}ms for ${workflows.length} workflows`
|
||||
)
|
||||
|
||||
// Return the workflows
|
||||
return NextResponse.json({ data: workflows }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
@@ -239,11 +241,13 @@ async function migrateOrphanedWorkflows(userId: string, workspaceId: string) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
|
||||
|
||||
logger.info(`Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}`)
|
||||
|
||||
logger.info(
|
||||
`Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}`
|
||||
)
|
||||
} catch (batchError) {
|
||||
logger.warn('Batch migration failed, falling back to individual updates:', batchError)
|
||||
|
||||
|
||||
// Fallback to individual updates if batch update fails
|
||||
for (const { id } of orphanedWorkflows) {
|
||||
try {
|
||||
@@ -316,8 +320,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Validate workspace membership and permissions
|
||||
let userRole: string | null = null;
|
||||
|
||||
let userRole: string | null = null
|
||||
|
||||
if (workspaceId) {
|
||||
const workspaceExists = await db
|
||||
.select({ id: workspace.id })
|
||||
@@ -357,10 +361,7 @@ export async function POST(req: NextRequest) {
|
||||
let dbWorkflows
|
||||
|
||||
if (workspaceId) {
|
||||
dbWorkflows = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
dbWorkflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
|
||||
} else {
|
||||
dbWorkflows = await db.select().from(workflow).where(eq(workflow.userId, session.user.id))
|
||||
}
|
||||
@@ -405,16 +406,17 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
} else {
|
||||
// Check if user has permission to update this workflow
|
||||
const canUpdate = dbWorkflow.userId === session.user.id ||
|
||||
(workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member'));
|
||||
|
||||
const canUpdate =
|
||||
dbWorkflow.userId === session.user.id ||
|
||||
(workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member'))
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted to update workflow ${id} without permission`
|
||||
)
|
||||
continue; // Skip this workflow update and move to the next one
|
||||
continue // Skip this workflow update and move to the next one
|
||||
}
|
||||
|
||||
|
||||
// Existing workflow - update if needed
|
||||
const needsUpdate =
|
||||
JSON.stringify(dbWorkflow.state) !== JSON.stringify(clientWorkflow.state) ||
|
||||
@@ -454,9 +456,10 @@ export async function POST(req: NextRequest) {
|
||||
) {
|
||||
// Check if the user has permission to delete this workflow
|
||||
// Users can delete their own workflows, or any workflow if they're a workspace owner/admin
|
||||
const canDelete = dbWorkflow.userId === session.user.id ||
|
||||
(workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member'));
|
||||
|
||||
const canDelete =
|
||||
dbWorkflow.userId === session.user.id ||
|
||||
(workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member'))
|
||||
|
||||
if (canDelete) {
|
||||
operations.push(db.delete(workflow).where(eq(workflow.id, dbWorkflow.id)))
|
||||
} else {
|
||||
@@ -471,14 +474,14 @@ export async function POST(req: NextRequest) {
|
||||
await Promise.all(operations)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats: {
|
||||
elapsed,
|
||||
operations: operations.length,
|
||||
workflows: Object.keys(clientWorkflows).length
|
||||
}
|
||||
workflows: Object.keys(clientWorkflows).length,
|
||||
},
|
||||
})
|
||||
} catch (validationError) {
|
||||
if (validationError instanceof z.ZodError) {
|
||||
|
||||
@@ -1,94 +1,133 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema'
|
||||
import { user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema'
|
||||
|
||||
// GET /api/workspaces/invitations/accept - Accept an invitation via token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
|
||||
if (!token) {
|
||||
// Redirect to a page explaining the error
|
||||
return NextResponse.redirect(new URL('/invite/invite-error?reason=missing-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=missing-token',
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// No need to encode API URL as callback, just redirect to invite page
|
||||
// The middleware will handle proper login flow and return to invite page
|
||||
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${token}?token=${token}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Find the invitation by token
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.token, token))
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.redirect(new URL('/invite/invite-error?reason=invalid-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=invalid-token',
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Check if invitation has expired
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
return NextResponse.redirect(new URL('/invite/invite-error?reason=expired', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=expired',
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Check if invitation is already accepted
|
||||
if (invitation.status !== 'pending') {
|
||||
return NextResponse.redirect(new URL('/invite/invite-error?reason=already-processed', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-processed',
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Get the user's email from the session
|
||||
const userEmail = session.user.email.toLowerCase()
|
||||
const invitationEmail = invitation.email.toLowerCase()
|
||||
|
||||
|
||||
// Check if the logged-in user's email matches the invitation
|
||||
// We'll use exact matching as the primary check
|
||||
const isExactMatch = userEmail === invitationEmail
|
||||
|
||||
|
||||
// For SSO or company email variants, check domain and normalized username
|
||||
// This handles cases like john.doe@company.com vs john@company.com
|
||||
const normalizeUsername = (email: string): string => {
|
||||
return email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '').toLowerCase()
|
||||
return email
|
||||
.split('@')[0]
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
|
||||
const isSameDomain = userEmail.split('@')[1] === invitationEmail.split('@')[1]
|
||||
const normalizedUserEmail = normalizeUsername(userEmail)
|
||||
const normalizedInvitationEmail = normalizeUsername(invitationEmail)
|
||||
const isSimilarUsername = normalizedUserEmail === normalizedInvitationEmail ||
|
||||
(normalizedUserEmail.includes(normalizedInvitationEmail) ||
|
||||
normalizedInvitationEmail.includes(normalizedUserEmail))
|
||||
|
||||
const isSimilarUsername =
|
||||
normalizedUserEmail === normalizedInvitationEmail ||
|
||||
normalizedUserEmail.includes(normalizedInvitationEmail) ||
|
||||
normalizedInvitationEmail.includes(normalizedUserEmail)
|
||||
|
||||
const isValidMatch = isExactMatch || (isSameDomain && isSimilarUsername)
|
||||
|
||||
|
||||
if (!isValidMatch) {
|
||||
// Get user info to include in the error message
|
||||
const userData = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.then(rows => rows[0])
|
||||
|
||||
return NextResponse.redirect(new URL(`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData?.email || session.user.email}`)}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData?.email || session.user.email}`)}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Get the workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.redirect(new URL('/invite/invite-error?reason=workspace-not-found', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=workspace-not-found',
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMembership = await db
|
||||
.select()
|
||||
@@ -99,8 +138,8 @@ export async function GET(req: NextRequest) {
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingMembership) {
|
||||
// User is already a member, just mark the invitation as accepted and redirect
|
||||
await db
|
||||
@@ -110,22 +149,25 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
|
||||
return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/w/${invitation.workspaceId}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Add user to workspace
|
||||
await db
|
||||
.insert(workspaceMember)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
workspaceId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: invitation.role,
|
||||
joinedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await db.insert(workspaceMember).values({
|
||||
id: randomUUID(),
|
||||
workspaceId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: invitation.role,
|
||||
joinedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark invitation as accepted
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
@@ -134,11 +176,21 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
|
||||
|
||||
// Redirect to the workspace
|
||||
return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/w/${invitation.workspaceId}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error accepting invitation:', error)
|
||||
return NextResponse.redirect(new URL('/invite/invite-error?reason=server-error', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=server-error',
|
||||
process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { workspace, workspaceInvitation } from '@/db/schema'
|
||||
@@ -7,52 +7,52 @@ import { workspace, workspaceInvitation } from '@/db/schema'
|
||||
// GET /api/workspaces/invitations/details - Get invitation details by token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Find the invitation by token
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.token, token))
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
// Check if invitation has expired
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
// Get workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
// Return the invitation with workspace name
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
workspaceName: workspaceDetails.name
|
||||
workspaceName: workspaceDetails.name,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { and, eq, sql, inArray } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema'
|
||||
import { render } from '@react-email/render'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { Resend } from 'resend'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { render } from '@react-email/render'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema'
|
||||
|
||||
// Initialize Resend for email sending
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
@@ -14,11 +14,11 @@ const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
// GET /api/workspaces/invitations - Get all invitations for the user's workspaces
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// First get all workspaces where the user is a member with owner role
|
||||
const userWorkspaces = await db
|
||||
@@ -32,22 +32,20 @@ export async function GET(req: NextRequest) {
|
||||
eq(workspaceMember.role, 'owner')
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if (userWorkspaces.length === 0) {
|
||||
return NextResponse.json({ invitations: [] })
|
||||
}
|
||||
|
||||
|
||||
// Get all workspaceIds where the user is an owner
|
||||
const workspaceIds = userWorkspaces.map(w => w.id)
|
||||
|
||||
const workspaceIds = userWorkspaces.map((w) => w.id)
|
||||
|
||||
// Find all invitations for those workspaces
|
||||
const invitations = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
inArray(workspaceInvitation.workspaceId, workspaceIds)
|
||||
)
|
||||
|
||||
.where(inArray(workspaceInvitation.workspaceId, workspaceIds))
|
||||
|
||||
return NextResponse.json({ invitations })
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace invitations:', error)
|
||||
@@ -58,18 +56,18 @@ export async function GET(req: NextRequest) {
|
||||
// POST /api/workspaces/invitations - Create a new invitation
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const { workspaceId, email, role = 'member' } = await req.json()
|
||||
|
||||
|
||||
if (!workspaceId || !email) {
|
||||
return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
// Check if user is authorized to invite to this workspace (must be owner)
|
||||
const membership = await db
|
||||
.select()
|
||||
@@ -80,31 +78,34 @@ export async function POST(req: NextRequest) {
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!membership || membership.role !== 'owner') {
|
||||
return NextResponse.json({ error: 'You are not authorized to invite to this workspace' }, { status: 403 })
|
||||
return NextResponse.json(
|
||||
{ error: 'You are not authorized to invite to this workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Get the workspace details for the email
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then(rows => rows[0])
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
// Check if the user is already a member
|
||||
// First find if a user with this email exists
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.email, email))
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingUser) {
|
||||
// Check if the user is already a member of this workspace
|
||||
const existingMembership = await db
|
||||
@@ -116,16 +117,19 @@ export async function POST(req: NextRequest) {
|
||||
eq(workspaceMember.userId, existingUser.id)
|
||||
)
|
||||
)
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingMembership) {
|
||||
return NextResponse.json({
|
||||
error: `${email} is already a member of this workspace`,
|
||||
email
|
||||
}, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${email} is already a member of this workspace`,
|
||||
email,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check if there's already a pending invitation
|
||||
const existingInvitation = await db
|
||||
.select()
|
||||
@@ -137,20 +141,23 @@ export async function POST(req: NextRequest) {
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingInvitation) {
|
||||
return NextResponse.json({
|
||||
error: `${email} has already been invited to this workspace`,
|
||||
email
|
||||
}, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${email} has already been invited to this workspace`,
|
||||
email,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Generate a unique token and set expiry date (1 week from now)
|
||||
const token = randomUUID()
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
|
||||
|
||||
|
||||
// Create the invitation
|
||||
const invitation = await db
|
||||
.insert(workspaceInvitation)
|
||||
@@ -167,8 +174,8 @@ export async function POST(req: NextRequest) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.then(rows => rows[0])
|
||||
|
||||
.then((rows) => rows[0])
|
||||
|
||||
// Send the invitation email
|
||||
await sendInvitationEmail({
|
||||
to: email,
|
||||
@@ -176,7 +183,7 @@ export async function POST(req: NextRequest) {
|
||||
workspaceName: workspaceDetails.name,
|
||||
token: token,
|
||||
})
|
||||
|
||||
|
||||
return NextResponse.json({ success: true, invitation })
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace invitation:', error)
|
||||
@@ -185,22 +192,22 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Helper function to send invitation email using the Resend API
|
||||
async function sendInvitationEmail({
|
||||
to,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
token
|
||||
}: {
|
||||
to: string;
|
||||
inviterName: string;
|
||||
workspaceName: string;
|
||||
token: string;
|
||||
async function sendInvitationEmail({
|
||||
to,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
token,
|
||||
}: {
|
||||
to: string
|
||||
inviterName: string
|
||||
workspaceName: string
|
||||
token: string
|
||||
}) {
|
||||
try {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
|
||||
// Always use the client-side invite route with token parameter
|
||||
const invitationLink = `${baseUrl}/invite/${token}?token=${token}`
|
||||
|
||||
|
||||
const emailHtml = await render(
|
||||
WorkspaceInvitationEmail({
|
||||
workspaceName,
|
||||
@@ -208,17 +215,17 @@ async function sendInvitationEmail({
|
||||
invitationLink,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || 'noreply@simstudio.ai',
|
||||
to,
|
||||
subject: `You've been invited to join "${workspaceName}" on Sim Studio`,
|
||||
html: emailHtml,
|
||||
})
|
||||
|
||||
|
||||
console.log(`Invitation email sent to ${to}`)
|
||||
} catch (error) {
|
||||
console.error('Error sending invitation email:', error)
|
||||
// Continue even if email fails - the invitation is still created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
|
||||
/* Custom Animations */
|
||||
@layer utilities {
|
||||
|
||||
/* Animation containment to avoid layout shifts */
|
||||
.animation-container {
|
||||
contain: paint layout style;
|
||||
@@ -206,11 +205,13 @@
|
||||
|
||||
@keyframes orbit {
|
||||
0% {
|
||||
transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg));
|
||||
transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px))
|
||||
rotate(calc(var(--angle) * -1deg));
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg));
|
||||
transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px))
|
||||
rotate(calc((var(--angle) * -1deg) - 360deg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,19 +242,23 @@
|
||||
.streaming-effect::after {
|
||||
content: '';
|
||||
@apply pointer-events-none absolute left-0 top-0 h-full w-full;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(128, 128, 128, 0) 0%,
|
||||
rgba(128, 128, 128, 0.1) 50%,
|
||||
rgba(128, 128, 128, 0) 100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(128, 128, 128, 0) 0%,
|
||||
rgba(128, 128, 128, 0.1) 50%,
|
||||
rgba(128, 128, 128, 0) 100%
|
||||
);
|
||||
animation: code-shimmer 1.5s infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dark .streaming-effect::after {
|
||||
background: linear-gradient(90deg,
|
||||
rgba(180, 180, 180, 0) 0%,
|
||||
rgba(180, 180, 180, 0.1) 50%,
|
||||
rgba(180, 180, 180, 0) 100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(180, 180, 180, 0) 0%,
|
||||
rgba(180, 180, 180, 0.1) 50%,
|
||||
rgba(180, 180, 180, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -298,7 +303,6 @@ input[type='search']::-ms-clear {
|
||||
|
||||
/* Code Prompt Bar Placeholder Animation */
|
||||
@keyframes placeholder-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
@@ -331,4 +335,4 @@ input[type='search']::-ms-clear {
|
||||
.main-content-overlay {
|
||||
z-index: 40;
|
||||
/* Higher z-index to appear above content */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +87,10 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'read:user:jira': 'Read your Jira user',
|
||||
'read:field-configuration:jira': 'Read your Jira field configuration',
|
||||
'read:issue-details:jira': 'Read your Jira issue details',
|
||||
'identify': 'Read your Discord user',
|
||||
'bot': 'Read your Discord bot',
|
||||
identify: 'Read your Discord user',
|
||||
bot: 'Read your Discord bot',
|
||||
'messages.read': 'Read your Discord messages',
|
||||
'guilds': 'Read your Discord guilds',
|
||||
guilds: 'Read your Discord guilds',
|
||||
'guilds.members.read': 'Read your Discord guild members',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { SubBlockConfig } from '@/blocks/types'
|
||||
import { ConfluenceFileInfo, ConfluenceFileSelector } from './components/confluence-file-selector'
|
||||
import { DiscordChannelInfo, DiscordChannelSelector } from './components/discord-channel-selector'
|
||||
import { FileInfo, GoogleDrivePicker } from './components/google-drive-picker'
|
||||
import { JiraIssueInfo, JiraIssueSelector } from './components/jira-issue-selector'
|
||||
import { DiscordChannelInfo, DiscordChannelSelector } from './components/discord-channel-selector'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -32,7 +32,8 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
|
||||
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : ''
|
||||
const credentials = isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : ''
|
||||
const credentials =
|
||||
isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : ''
|
||||
// For Discord, we need the bot token and server ID
|
||||
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
|
||||
const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : ''
|
||||
|
||||
@@ -97,7 +97,7 @@ export function DiscordServerSelector({
|
||||
// Handle open change - only fetch servers when the dropdown is opened
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
|
||||
|
||||
// Only fetch servers when opening the dropdown and if we have a valid token
|
||||
if (isOpen && botToken && (!initialFetchDone || servers.length === 0)) {
|
||||
fetchServers()
|
||||
@@ -108,10 +108,10 @@ export function DiscordServerSelector({
|
||||
// This is more efficient than fetching all servers
|
||||
const fetchSelectedServerInfo = useCallback(async () => {
|
||||
if (!botToken || !selectedServerId) return
|
||||
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
|
||||
try {
|
||||
// Only fetch the specific server by ID instead of all servers
|
||||
const response = await fetch('/api/auth/oauth/discord/servers', {
|
||||
@@ -119,17 +119,17 @@ export function DiscordServerSelector({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
botToken,
|
||||
serverId: selectedServerId
|
||||
serverId: selectedServerId,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to fetch Discord server')
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json()
|
||||
if (data.server) {
|
||||
setSelectedServer(data.server)
|
||||
@@ -160,10 +160,10 @@ export function DiscordServerSelector({
|
||||
useEffect(() => {
|
||||
if (value !== selectedServerId) {
|
||||
setSelectedServerId(value)
|
||||
|
||||
|
||||
// Find server info for the new value
|
||||
if (value && servers.length > 0) {
|
||||
const serverInfo = servers.find(server => server.id === value)
|
||||
const serverInfo = servers.find((server) => server.id === value)
|
||||
setSelectedServer(serverInfo || null)
|
||||
} else if (value) {
|
||||
// If we have a value but no server info, we might need to fetch it
|
||||
@@ -314,13 +314,11 @@ export function DiscordServerSelector({
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1 min-w-0">
|
||||
<h4 className="text-xs font-medium truncate">{selectedServer.name}</h4>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Server ID: {selectedServer.id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Server ID: {selectedServer.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,13 +474,13 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{config.docsLink ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(config.docsLink, '_target', 'noopener,noreferrer');
|
||||
e.stopPropagation()
|
||||
window.open(config.docsLink, '_target', 'noopener,noreferrer')
|
||||
}}
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
@@ -498,12 +498,12 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
|
||||
<p className="text-sm text-muted-foreground">{config.longDescription}</p>
|
||||
{config.docsLink && (
|
||||
<p className="text-xs text-blue-500 mt-1">
|
||||
<a
|
||||
href={config.docsLink}
|
||||
<a
|
||||
href={config.docsLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
View Documentation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Building, CheckCircle, Copy, PlusCircle, RefreshCw, UserX, XCircle } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { CheckCircle, Copy, PlusCircle, RefreshCw, UserX, XCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -20,6 +20,69 @@ import { checkEnterprisePlan } from '@/lib/subscription/utils'
|
||||
|
||||
const logger = createLogger('TeamManagement')
|
||||
|
||||
type User = { name?: string; email?: string }
|
||||
|
||||
type Member = {
|
||||
id: string
|
||||
role: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
type Invitation = {
|
||||
id: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
type Organization = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
members?: Member[]
|
||||
invitations?: Invitation[]
|
||||
createdAt: string | Date
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface SubscriptionMetadata {
|
||||
perSeatAllowance?: number
|
||||
totalAllowance?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type Subscription = {
|
||||
id: string
|
||||
plan: string
|
||||
status: string
|
||||
seats?: number
|
||||
referenceId: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
periodEnd?: number | Date
|
||||
trialEnd?: number | Date
|
||||
metadata?: SubscriptionMetadata
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function calculateSeatUsage(org?: Organization | null) {
|
||||
const members = org?.members?.length ?? 0
|
||||
const pending = org?.invitations?.filter((inv) => inv.status === 'pending').length ?? 0
|
||||
return { used: members + pending, members, pending }
|
||||
}
|
||||
|
||||
function useOrganizationRole(userEmail: string | undefined, org: Organization | null | undefined) {
|
||||
return useMemo(() => {
|
||||
if (!userEmail || !org?.members) {
|
||||
return { userRole: 'member', isAdminOrOwner: false }
|
||||
}
|
||||
const currentMember = org.members.find((m) => m.user?.email === userEmail)
|
||||
const role = currentMember?.role ?? 'member'
|
||||
return {
|
||||
userRole: role,
|
||||
isAdminOrOwner: role === 'owner' || role === 'admin',
|
||||
}
|
||||
}, [userEmail, org])
|
||||
}
|
||||
|
||||
export function TeamManagement() {
|
||||
const { data: session } = useSession()
|
||||
const { data: activeOrg } = client.useActiveOrganization()
|
||||
@@ -41,13 +104,16 @@ export function TeamManagement() {
|
||||
const [orgSlug, setOrgSlug] = useState('')
|
||||
const [inviteSuccess, setInviteSuccess] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('members')
|
||||
const [activeOrganization, setActiveOrganization] = useState<any>(null)
|
||||
const [subscriptionData, setSubscriptionData] = useState<any>(null)
|
||||
const [activeOrganization, setActiveOrganization] = useState<Organization | null>(null)
|
||||
const [subscriptionData, setSubscriptionData] = useState<Subscription | null>(null)
|
||||
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false)
|
||||
const [hasTeamPlan, setHasTeamPlan] = useState(false)
|
||||
const [hasEnterprisePlan, setHasEnterprisePlan] = useState(false)
|
||||
const [userRole, setUserRole] = useState<string>('member')
|
||||
const [isAdminOrOwner, setIsAdminOrOwner] = useState(false)
|
||||
const { userRole, isAdminOrOwner } = useOrganizationRole(session?.user?.email, activeOrganization)
|
||||
const { used: usedSeats } = useMemo(
|
||||
() => calculateSeatUsage(activeOrganization),
|
||||
[activeOrganization]
|
||||
)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!session?.user) return
|
||||
@@ -85,28 +151,12 @@ export function TeamManagement() {
|
||||
if (activeOrg) {
|
||||
setActiveOrganization(activeOrg)
|
||||
|
||||
// Determine the user's role in this organization
|
||||
if (session?.user?.email && activeOrg.members) {
|
||||
const currentMember = activeOrg.members.find(
|
||||
(m: any) => m.user?.email === session.user?.email
|
||||
)
|
||||
|
||||
if (currentMember) {
|
||||
setUserRole(currentMember.role)
|
||||
setIsAdminOrOwner(currentMember.role === 'owner' || currentMember.role === 'admin')
|
||||
logger.info('User role in organization', {
|
||||
role: currentMember.role,
|
||||
isAdminOrOwner: currentMember.role === 'owner' || currentMember.role === 'admin',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load subscription data for the organization
|
||||
if (activeOrg.id) {
|
||||
loadOrganizationSubscription(activeOrg.id)
|
||||
}
|
||||
}
|
||||
}, [activeOrg, session?.user?.email])
|
||||
}, [activeOrg])
|
||||
|
||||
// Load organization's subscription data
|
||||
const loadOrganizationSubscription = async (orgId: string) => {
|
||||
@@ -163,7 +213,7 @@ export function TeamManagement() {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching enterprise subscription', err)
|
||||
logger.error('Error fetching enterprise subscription', { error: err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,19 +255,20 @@ export function TeamManagement() {
|
||||
const handleReduceSeats = async () => {
|
||||
if (!session?.user || !activeOrganization || !subscriptionData) return
|
||||
|
||||
// Don't allow enterprise users to modify seats
|
||||
if (checkEnterprisePlan(subscriptionData)) {
|
||||
setError('Enterprise plan seats can only be modified by contacting support')
|
||||
return
|
||||
}
|
||||
|
||||
const currentSeats = subscriptionData.seats || 0
|
||||
if (currentSeats <= 1) {
|
||||
setError('Cannot reduce seats below 1')
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate current usage
|
||||
const currentMemberCount = activeOrganization.members?.length || 0
|
||||
const pendingInvitationCount =
|
||||
activeOrganization.invitations?.filter((inv: any) => inv.status === 'pending').length || 0
|
||||
const totalCount = currentMemberCount + pendingInvitationCount
|
||||
const { used: totalCount } = calculateSeatUsage(activeOrganization)
|
||||
|
||||
// Check if we need to remove members before reducing seats
|
||||
if (totalCount >= currentSeats) {
|
||||
setError(
|
||||
`You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.`
|
||||
@@ -226,66 +277,23 @@ export function TeamManagement() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Reduce the seats by 1
|
||||
const newSeatCount = currentSeats - 1
|
||||
|
||||
// If it's an enterprise plan, handle through custom endpoint
|
||||
if (checkEnterprisePlan(subscriptionData)) {
|
||||
// For enterprise plans, update via admin endpoint with credentials
|
||||
const response = await fetch('/api/user/subscription/update-seats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscriptionId: subscriptionData.id,
|
||||
seats: newSeatCount,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update seat count')
|
||||
}
|
||||
} else {
|
||||
// For team plans, use the client API
|
||||
const { error } = await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: activeOrganization.id,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
seats: newSeatCount,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to update seat count')
|
||||
}
|
||||
}
|
||||
|
||||
await updateSeats(currentSeats - 1)
|
||||
await refreshOrganization()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to reduce seats')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a slug from organization name
|
||||
const generateSlug = (name: string) => {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]/g, '-')
|
||||
}
|
||||
|
||||
// Handle organization name change
|
||||
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value
|
||||
setOrgName(newName)
|
||||
setOrgSlug(generateSlug(newName))
|
||||
}
|
||||
|
||||
// Create a new organization
|
||||
const handleCreateOrganization = async () => {
|
||||
if (!session?.user) return
|
||||
|
||||
@@ -318,10 +326,44 @@ export function TeamManagement() {
|
||||
// directly through a custom API endpoint instead of using upgrade
|
||||
if (hasTeamPlan || hasEnterprisePlan) {
|
||||
const userSubResponse = await client.subscription.list()
|
||||
const teamSubscription = userSubResponse.data?.find(
|
||||
|
||||
let teamSubscription = userSubResponse.data?.find(
|
||||
(sub) => (sub.plan === 'team' || sub.plan === 'enterprise') && sub.status === 'active'
|
||||
)
|
||||
|
||||
// If no subscription was found through the client API but user has enterprise plan,
|
||||
// fetch it directly through our enterprise subscription endpoint
|
||||
if (!teamSubscription && hasEnterprisePlan) {
|
||||
logger.info('No subscription found via client API, checking enterprise endpoint')
|
||||
try {
|
||||
const enterpriseResponse = await fetch('/api/user/subscription/enterprise')
|
||||
if (enterpriseResponse.ok) {
|
||||
const enterpriseData = await enterpriseResponse.json()
|
||||
if (enterpriseData.subscription) {
|
||||
teamSubscription = enterpriseData.subscription
|
||||
logger.info('Found enterprise subscription via direct API', {
|
||||
subscriptionId: teamSubscription?.id,
|
||||
plan: teamSubscription?.plan,
|
||||
seats: teamSubscription?.seats,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching enterprise subscription details', { error: err })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Team subscription to transfer', {
|
||||
found: !!teamSubscription,
|
||||
details: teamSubscription
|
||||
? {
|
||||
id: teamSubscription.id,
|
||||
plan: teamSubscription.plan,
|
||||
status: teamSubscription.status,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
if (teamSubscription) {
|
||||
logger.info('Found subscription to transfer', {
|
||||
subscriptionId: teamSubscription.id,
|
||||
@@ -331,23 +373,40 @@ export function TeamManagement() {
|
||||
})
|
||||
|
||||
// Use a custom API endpoint to transfer the subscription without going to Stripe
|
||||
const transferResponse = await fetch('/api/user/transfer-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscriptionId: teamSubscription.id,
|
||||
organizationId: orgId,
|
||||
}),
|
||||
})
|
||||
try {
|
||||
const transferResponse = await fetch(
|
||||
`/api/user/subscription/${teamSubscription.id}/transfer`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
organizationId: orgId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!transferResponse.ok) {
|
||||
const errorData = await transferResponse.json()
|
||||
throw new Error(errorData.error || 'Failed to transfer subscription to organization')
|
||||
if (!transferResponse.ok) {
|
||||
const errorText = await transferResponse.text()
|
||||
let errorMessage = 'Failed to transfer subscription'
|
||||
|
||||
try {
|
||||
if (errorText && errorText.trim().startsWith('{')) {
|
||||
const errorData = JSON.parse(errorText)
|
||||
errorMessage = errorData.error || errorMessage
|
||||
}
|
||||
} catch (e) {
|
||||
// Parsing failed, use the raw text
|
||||
errorMessage = errorText || errorMessage
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
} catch (transferError) {
|
||||
logger.error('Subscription transfer failed', {
|
||||
error: transferError instanceof Error ? transferError.message : String(transferError),
|
||||
})
|
||||
throw transferError
|
||||
}
|
||||
|
||||
logger.info('Successfully transferred subscription to organization')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,30 +481,26 @@ export function TeamManagement() {
|
||||
setError(null)
|
||||
setInviteSuccess(false)
|
||||
|
||||
// Check seat limit - compare current members + pending invitations against seats
|
||||
const currentMemberCount = activeOrganization.members?.length || 0
|
||||
const pendingInvitationCount =
|
||||
activeOrganization.invitations?.filter((inv: any) => inv.status === 'pending').length || 0
|
||||
const totalCount = currentMemberCount + pendingInvitationCount
|
||||
const {
|
||||
used: totalCount,
|
||||
pending: pendingInvitationCount,
|
||||
members: currentMemberCount,
|
||||
} = calculateSeatUsage(activeOrganization)
|
||||
|
||||
// Get the number of seats from subscription data
|
||||
const seatLimit = subscriptionData?.seats || 0
|
||||
|
||||
logger.info('Checking seat availability for invitation', {
|
||||
currentMembers: currentMemberCount,
|
||||
pendingInvites: pendingInvitationCount,
|
||||
totalUsed: totalCount,
|
||||
seatLimit: seatLimit,
|
||||
seatLimit,
|
||||
subscriptionId: subscriptionData?.id,
|
||||
})
|
||||
|
||||
if (totalCount >= seatLimit) {
|
||||
const error = `You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.`
|
||||
logger.warn('Invitation failed - seat limit reached', {
|
||||
totalCount,
|
||||
seatLimit,
|
||||
})
|
||||
setError(error)
|
||||
setError(
|
||||
`You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -516,35 +571,11 @@ export function TeamManagement() {
|
||||
// If the user opted to reduce seats as well
|
||||
if (shouldReduceSeats && subscriptionData) {
|
||||
const currentSeats = subscriptionData.seats || 0
|
||||
|
||||
if (currentSeats > 1) {
|
||||
// Determine if we're dealing with enterprise or team plan
|
||||
if (checkEnterprisePlan(subscriptionData)) {
|
||||
// Handle enterprise plan seat reduction
|
||||
const response = await fetch('/api/user/subscription/update-seats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscriptionId: subscriptionData.id,
|
||||
seats: currentSeats - 1,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to reduce seats')
|
||||
}
|
||||
} else {
|
||||
// Handle team plan seat reduction
|
||||
await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: activeOrganization.id,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
seats: currentSeats - 1,
|
||||
})
|
||||
try {
|
||||
await updateSeats(currentSeats - 1)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,7 +613,6 @@ export function TeamManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the effective plan name for display
|
||||
const getEffectivePlanName = () => {
|
||||
if (!subscriptionData) return 'No Plan'
|
||||
|
||||
@@ -598,6 +628,35 @@ export function TeamManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateSeats = useCallback(
|
||||
async (newSeatCount: number) => {
|
||||
if (!subscriptionData || !activeOrganization) return
|
||||
|
||||
// Don't allow enterprise users to modify seats
|
||||
if (checkEnterprisePlan(subscriptionData)) {
|
||||
setError('Enterprise plan seats can only be modified by contacting support')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const { error } = await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: activeOrganization.id,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
seats: newSeatCount,
|
||||
})
|
||||
if (error) throw new Error(error.message || 'Failed to update seats')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[subscriptionData, activeOrganization]
|
||||
)
|
||||
|
||||
if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
|
||||
return <TeamManagementSkeleton />
|
||||
}
|
||||
@@ -836,76 +895,47 @@ export function TeamManagement() {
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span>Used</span>
|
||||
<span>
|
||||
{(activeOrganization.members?.length || 0) +
|
||||
(activeOrganization.invitations?.filter(
|
||||
(inv: any) => inv.status === 'pending'
|
||||
).length || 0)}
|
||||
/{subscriptionData.seats || 0}
|
||||
{usedSeats}/{subscriptionData.seats || 0}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
(((activeOrganization.members?.length || 0) +
|
||||
(activeOrganization.invitations?.filter(
|
||||
(inv: any) => inv.status === 'pending'
|
||||
).length || 0)) /
|
||||
(subscriptionData.seats || 1)) *
|
||||
100
|
||||
}
|
||||
value={(usedSeats / (subscriptionData.seats || 1)) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReduceSeats}
|
||||
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
|
||||
>
|
||||
Remove Seat
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const currentSeats = subscriptionData.seats || 1
|
||||
|
||||
// For enterprise plans, we need a custom endpoint
|
||||
if (checkEnterprisePlan(subscriptionData)) {
|
||||
{checkEnterprisePlan(subscriptionData) ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<div className="mt-4 flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReduceSeats}
|
||||
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
|
||||
>
|
||||
Remove Seat
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const currentSeats = subscriptionData.seats || 1
|
||||
try {
|
||||
const response = await fetch('/api/user/subscription/update-seats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscriptionId: subscriptionData.id,
|
||||
seats: currentSeats + 1,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update seats')
|
||||
}
|
||||
|
||||
await updateSeats(currentSeats + 1)
|
||||
await refreshOrganization()
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update seats'
|
||||
setError(errorMessage)
|
||||
logger.error('Error updating enterprise seats', { error })
|
||||
logger.error('Error updating seats', { error })
|
||||
}
|
||||
} else {
|
||||
// For team plans, use the normal upgrade flow
|
||||
await confirmTeamUpgrade(currentSeats + 1)
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Add Seat
|
||||
</Button>
|
||||
</div>
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Add Seat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
@@ -969,7 +999,7 @@ export function TeamManagement() {
|
||||
</div>
|
||||
|
||||
{/* Pending Invitations - only show to admins/owners */}
|
||||
{isAdminOrOwner && activeOrganization.invitations?.length > 0 && (
|
||||
{isAdminOrOwner && (activeOrganization.invitations?.length ?? 0) > 0 && (
|
||||
<div className="rounded-md border">
|
||||
<h4 className="text-sm font-medium p-4 border-b">Pending Invitations</h4>
|
||||
|
||||
@@ -1166,7 +1196,6 @@ export function TeamManagement() {
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton component for team management loading state
|
||||
function TeamManagementSkeleton() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -1219,12 +1248,10 @@ function TeamManagementSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton component for loading state in buttons
|
||||
function ButtonSkeleton() {
|
||||
return <Skeleton className="h-9 w-24" />
|
||||
}
|
||||
|
||||
// Skeleton component for loading state in team seats
|
||||
function TeamSeatsSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -1359,13 +1359,24 @@ export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} width="800px" height="800px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fillRule="nonzero">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
{...props}
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 -28.5 256 256"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z"
|
||||
fill="#5865F2"
|
||||
fillRule="nonzero"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,15 @@ import { cn } from '@/lib/utils'
|
||||
// This file is not typed correctly from shadcn, so we're disabling the type checker
|
||||
// @ts-nocheck
|
||||
|
||||
// This file is not typed correctly from shadcn, so we're disabling the type checker
|
||||
// @ts-nocheck
|
||||
|
||||
// This file is not typed correctly from shadcn, so we're disabling the type checker
|
||||
// @ts-nocheck
|
||||
|
||||
// This file is not typed correctly from shadcn, so we're disabling the type checker
|
||||
// @ts-nocheck
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive> & {
|
||||
|
||||
@@ -10,4 +10,4 @@ export default {
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
} satisfies Config
|
||||
} satisfies Config
|
||||
|
||||
@@ -244,7 +244,7 @@ export class InputResolver {
|
||||
try {
|
||||
// Handle 'string' type the same as 'plain' for backward compatibility
|
||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||
|
||||
|
||||
// Use the centralized VariableManager to resolve variable values
|
||||
return VariableManager.resolveForExecution(variable.value, type)
|
||||
} catch (error) {
|
||||
@@ -271,12 +271,12 @@ export class InputResolver {
|
||||
try {
|
||||
// Handle 'string' type the same as 'plain' for backward compatibility
|
||||
const normalizedType = type === 'string' ? 'plain' : type
|
||||
|
||||
|
||||
// For plain text, use exactly what's entered without modifications
|
||||
if (normalizedType === 'plain' && typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
// Determine if this needs special handling for code contexts
|
||||
const needsCodeStringLiteral = this.needsCodeStringLiteral(currentBlock, String(value))
|
||||
const isFunctionBlock = currentBlock?.metadata?.id === 'function'
|
||||
@@ -1081,7 +1081,7 @@ export class InputResolver {
|
||||
if (block.metadata.id === 'function') {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Specifically for condition blocks, stringifyForCondition handles quoting
|
||||
// so we don't need extra quoting here unless it's within an expression.
|
||||
if (block.metadata.id === 'condition' && !expression) {
|
||||
|
||||
@@ -868,15 +868,14 @@ export const auth = betterAuth({
|
||||
organization({
|
||||
// Allow team plan subscribers to create organizations
|
||||
allowUserToCreateOrganization: async (user) => {
|
||||
// Get subscription data
|
||||
const dbSubscriptions = await db
|
||||
.select()
|
||||
.from(schema.subscription)
|
||||
.where(eq(schema.subscription.referenceId, user.id))
|
||||
|
||||
// Check if user has active team subscription
|
||||
const hasTeamPlan = dbSubscriptions.some(
|
||||
(sub) => sub.status === 'active' && sub.plan === 'team'
|
||||
(sub) =>
|
||||
sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'enterprise')
|
||||
)
|
||||
|
||||
return hasTeamPlan
|
||||
@@ -885,7 +884,6 @@ export const auth = betterAuth({
|
||||
membershipLimit: 50,
|
||||
// Validate seat limits before sending invitations
|
||||
beforeInvite: async ({ organization }: { organization: { id: string } }) => {
|
||||
// Get subscription for this organization
|
||||
const subscriptions = await db
|
||||
.select()
|
||||
.from(schema.subscription)
|
||||
@@ -902,7 +900,6 @@ export const auth = betterAuth({
|
||||
throw new Error('No active team subscription for this organization')
|
||||
}
|
||||
|
||||
// Count current members + pending invitations
|
||||
const members = await db
|
||||
.select()
|
||||
.from(schema.member)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
|
||||
import {
|
||||
AirtableIcon,
|
||||
ConfluenceIcon,
|
||||
DiscordIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
GoogleCalendarIcon,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
NotionIcon,
|
||||
SupabaseIcon,
|
||||
xIcon,
|
||||
DiscordIcon,
|
||||
} from '@/components/icons'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
@@ -45,7 +45,7 @@ export type OAuthService =
|
||||
| 'notion'
|
||||
| 'jira'
|
||||
| 'discord'
|
||||
|
||||
|
||||
// Define the interface for OAuth provider configuration
|
||||
export interface OAuthProviderConfig {
|
||||
id: OAuthProvider
|
||||
@@ -251,13 +251,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
providerId: 'discord',
|
||||
icon: (props) => DiscordIcon(props),
|
||||
baseProviderIcon: (props) => DiscordIcon(props),
|
||||
scopes: [
|
||||
'identify',
|
||||
'bot',
|
||||
'messages.read',
|
||||
'guilds',
|
||||
'guilds.members.read',
|
||||
],
|
||||
scopes: ['identify', 'bot', 'messages.read', 'guilds', 'guilds.members.read'],
|
||||
},
|
||||
},
|
||||
defaultService: 'discord',
|
||||
@@ -501,7 +495,12 @@ export async function refreshOAuthToken(
|
||||
} else {
|
||||
throw new Error('Both client ID and client secret are required for Airtable OAuth')
|
||||
}
|
||||
} else if (provider === 'x' || provider === 'confluence' || provider === 'jira' || provider === 'discord') {
|
||||
} else if (
|
||||
provider === 'x' ||
|
||||
provider === 'confluence' ||
|
||||
provider === 'jira' ||
|
||||
provider === 'discord'
|
||||
) {
|
||||
const authString = `${clientId}:${clientSecret}`
|
||||
const basicAuth = Buffer.from(authString).toString('base64')
|
||||
headers['Authorization'] = `Basic ${basicAuth}`
|
||||
|
||||
@@ -226,9 +226,11 @@ export class VariableManager {
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
} else if (type === 'string') {
|
||||
// For backwards compatibility, add quotes only for string type in code context
|
||||
return typeof value === 'string' ? JSON.stringify(value) : this.formatValue(value, type, 'code')
|
||||
return typeof value === 'string'
|
||||
? JSON.stringify(value)
|
||||
: this.formatValue(value, type, 'code')
|
||||
}
|
||||
|
||||
|
||||
return this.formatValue(value, type, 'code')
|
||||
}
|
||||
|
||||
|
||||
@@ -679,24 +679,26 @@ export function verifyProviderWebhook(
|
||||
// Log the user agent for debugging purposes
|
||||
const userAgent = request.headers.get('user-agent') || ''
|
||||
logger.debug(`[${requestId}] Telegram webhook request received with User-Agent: ${userAgent}`)
|
||||
|
||||
|
||||
// Check if the user agent is empty and warn about it
|
||||
if (!userAgent) {
|
||||
logger.warn(`[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.`)
|
||||
logger.warn(
|
||||
`[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.`
|
||||
)
|
||||
}
|
||||
|
||||
// We'll accept the request anyway since we're in the provider-specific logic,
|
||||
// but we'll log the information for debugging
|
||||
|
||||
|
||||
// Telegram uses IP addresses in specific ranges
|
||||
// This is optional verification that could be added if IP verification is needed
|
||||
const clientIp =
|
||||
const clientIp =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
|
||||
|
||||
logger.debug(`[${requestId}] Telegram webhook request from IP: ${clientIp}`)
|
||||
|
||||
|
||||
break
|
||||
case 'generic':
|
||||
// Generic auth logic: requireAuth, token, secretHeaderName, allowedIps
|
||||
|
||||
@@ -56,44 +56,43 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
// Allow access to invitation links
|
||||
if (request.nextUrl.pathname.startsWith('/invite/')) {
|
||||
// If this is an invitation and the user is not logged in,
|
||||
// If this is an invitation and the user is not logged in,
|
||||
// and this isn't a login/signup-related request, redirect to login
|
||||
if (!hasActiveSession &&
|
||||
!request.nextUrl.pathname.endsWith('/login') &&
|
||||
!request.nextUrl.pathname.endsWith('/signup') &&
|
||||
!request.nextUrl.search.includes('callbackUrl')) {
|
||||
|
||||
if (
|
||||
!hasActiveSession &&
|
||||
!request.nextUrl.pathname.endsWith('/login') &&
|
||||
!request.nextUrl.pathname.endsWith('/signup') &&
|
||||
!request.nextUrl.search.includes('callbackUrl')
|
||||
) {
|
||||
// Prepare invitation URL for callback after login
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
const inviteId = request.nextUrl.pathname.split('/').pop();
|
||||
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
const inviteId = request.nextUrl.pathname.split('/').pop()
|
||||
|
||||
// Build the callback URL - retain the invitation path with token
|
||||
const callbackParam = encodeURIComponent(
|
||||
`/invite/${inviteId}${token ? `?token=${token}` : ''}`
|
||||
);
|
||||
|
||||
)
|
||||
|
||||
// Redirect to login with callback
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
||||
// Allow access to workspace invitation API endpoint
|
||||
if (request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
|
||||
// If the endpoint is for accepting an invitation and user is not logged in
|
||||
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
if (token) {
|
||||
// Redirect to the client-side invite page instead of directly to login
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${token}?token=${token}`, request.url)
|
||||
);
|
||||
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
|
||||
}
|
||||
}
|
||||
return NextResponse.next();
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Handle protected routes that require authentication
|
||||
@@ -115,10 +114,17 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Handle waitlist protection for login and signup in production
|
||||
if (url.pathname === '/login' || url.pathname === '/signup' ||
|
||||
url.pathname === '/auth/login' || url.pathname === '/auth/signup') {
|
||||
if (
|
||||
url.pathname === '/login' ||
|
||||
url.pathname === '/signup' ||
|
||||
url.pathname === '/auth/login' ||
|
||||
url.pathname === '/auth/signup'
|
||||
) {
|
||||
// If this is the login page and user has logged in before, allow access
|
||||
if (hasPreviouslyLoggedIn && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/auth/login')) {
|
||||
if (
|
||||
hasPreviouslyLoggedIn &&
|
||||
(request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/auth/login')
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import { useNotificationStore } from './notifications/store'
|
||||
import { useConsoleStore } from './panel/console/store'
|
||||
import { useVariablesStore } from './panel/variables/store'
|
||||
import { useEnvironmentStore } from './settings/environment/store'
|
||||
import {
|
||||
getSyncManagers,
|
||||
initializeSyncManagers,
|
||||
resetSyncManagers,
|
||||
isSyncInitialized
|
||||
import {
|
||||
getSyncManagers,
|
||||
initializeSyncManagers,
|
||||
isSyncInitialized,
|
||||
resetSyncManagers,
|
||||
} from './sync-registry'
|
||||
// Import the syncWorkflows function directly
|
||||
import { syncWorkflows } from './workflows'
|
||||
import {
|
||||
loadRegistry,
|
||||
loadSubblockValues,
|
||||
@@ -23,17 +25,10 @@ import {
|
||||
} from './workflows/persistence'
|
||||
import { useWorkflowRegistry } from './workflows/registry/store'
|
||||
import { useSubBlockStore } from './workflows/subblock/store'
|
||||
import {
|
||||
workflowSync,
|
||||
isRegistryInitialized,
|
||||
markWorkflowsDirty
|
||||
} from './workflows/sync'
|
||||
import { isRegistryInitialized, markWorkflowsDirty, workflowSync } from './workflows/sync'
|
||||
import { useWorkflowStore } from './workflows/workflow/store'
|
||||
import { BlockState } from './workflows/workflow/types'
|
||||
|
||||
// Import the syncWorkflows function directly
|
||||
import { syncWorkflows } from './workflows'
|
||||
|
||||
const logger = createLogger('Stores')
|
||||
|
||||
// Track initialization state
|
||||
@@ -53,7 +48,7 @@ async function initializeApplication(): Promise<void> {
|
||||
|
||||
isInitializing = true
|
||||
appFullyInitialized = false
|
||||
|
||||
|
||||
// Track initialization start time
|
||||
const initStartTime = Date.now()
|
||||
|
||||
@@ -105,11 +100,11 @@ async function initializeApplication(): Promise<void> {
|
||||
|
||||
// 2. Register cleanup
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
|
||||
// Log initialization timing information
|
||||
const initDuration = Date.now() - initStartTime
|
||||
logger.info(`Application initialization completed in ${initDuration}ms`)
|
||||
|
||||
|
||||
// Mark application as fully initialized
|
||||
appFullyInitialized = true
|
||||
} catch (error) {
|
||||
@@ -125,7 +120,7 @@ async function initializeApplication(): Promise<void> {
|
||||
* Checks if application is fully initialized
|
||||
*/
|
||||
export function isAppInitialized(): boolean {
|
||||
return appFullyInitialized && isRegistryInitialized() && isSyncInitialized();
|
||||
return appFullyInitialized && isRegistryInitialized() && isSyncInitialized()
|
||||
}
|
||||
|
||||
function initializeWorkflowState(workflowId: string): void {
|
||||
@@ -200,7 +195,7 @@ function handleBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
}
|
||||
|
||||
// Mark workflows as dirty to ensure sync on exit
|
||||
syncWorkflows();
|
||||
syncWorkflows()
|
||||
|
||||
// 2. Final sync for managers that need it
|
||||
getSyncManagers()
|
||||
@@ -348,7 +343,7 @@ export async function reinitializeAfterLogin(): Promise<void> {
|
||||
try {
|
||||
// Reset application initialization state
|
||||
appFullyInitialized = false
|
||||
|
||||
|
||||
// Reset sync managers to prevent any active syncs during reinitialization
|
||||
resetSyncManagers()
|
||||
|
||||
@@ -491,8 +486,8 @@ function createFirstWorkflowWithAgentBlock(): void {
|
||||
saveWorkflowState(workflowId, updatedState)
|
||||
|
||||
// Mark as dirty to ensure sync
|
||||
syncWorkflows();
|
||||
|
||||
syncWorkflows()
|
||||
|
||||
// Resume sync managers after initialization
|
||||
setTimeout(() => {
|
||||
const syncManagers = getSyncManagers()
|
||||
|
||||
@@ -173,18 +173,18 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
|
||||
// Use the same debounced save mechanism as updateVariable
|
||||
const workflowId = variable.workflowId
|
||||
|
||||
|
||||
// Clear existing timer for this workflow if it exists
|
||||
if (saveTimers.has(workflowId)) {
|
||||
clearTimeout(saveTimers.get(workflowId))
|
||||
}
|
||||
|
||||
|
||||
// Set new debounced save timer
|
||||
const timer = setTimeout(() => {
|
||||
get().saveVariables(workflowId)
|
||||
saveTimers.delete(workflowId)
|
||||
}, SAVE_DEBOUNCE_DELAY)
|
||||
|
||||
|
||||
saveTimers.set(workflowId, timer)
|
||||
|
||||
return id
|
||||
@@ -290,14 +290,14 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
if (update.type === 'string') {
|
||||
update = { ...update, type: 'plain' }
|
||||
}
|
||||
|
||||
|
||||
// Create updated variable to check for validation
|
||||
const updatedVariable: Variable = {
|
||||
...state.variables[id],
|
||||
...update,
|
||||
validationError: undefined, // Initialize property to be updated later
|
||||
}
|
||||
|
||||
|
||||
// If the type or value changed, check for validation errors
|
||||
if (update.type || update.value !== undefined) {
|
||||
// Only add validation feedback - never modify the value
|
||||
@@ -341,13 +341,13 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
if (saveTimers.has(workflowId)) {
|
||||
clearTimeout(saveTimers.get(workflowId))
|
||||
}
|
||||
|
||||
|
||||
// Set new debounced save timer
|
||||
const timer = setTimeout(() => {
|
||||
get().saveVariables(workflowId)
|
||||
saveTimers.delete(workflowId)
|
||||
}, SAVE_DEBOUNCE_DELAY)
|
||||
|
||||
|
||||
saveTimers.set(workflowId, timer)
|
||||
|
||||
return { variables: rest }
|
||||
@@ -372,7 +372,7 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
uniqueName = `${baseName} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
|
||||
// Mark this duplicated variable as recently added
|
||||
recentlyAddedVariables.set(newId, Date.now())
|
||||
|
||||
@@ -391,18 +391,18 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
|
||||
// Use the same debounced save mechanism
|
||||
const workflowId = variable.workflowId
|
||||
|
||||
|
||||
// Clear existing timer for this workflow if it exists
|
||||
if (saveTimers.has(workflowId)) {
|
||||
clearTimeout(saveTimers.get(workflowId))
|
||||
}
|
||||
|
||||
|
||||
// Set new debounced save timer
|
||||
const timer = setTimeout(() => {
|
||||
get().saveVariables(workflowId)
|
||||
saveTimers.delete(workflowId)
|
||||
}, SAVE_DEBOUNCE_DELAY)
|
||||
|
||||
|
||||
saveTimers.set(workflowId, timer)
|
||||
|
||||
return newId
|
||||
@@ -413,20 +413,22 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
// we check for the special case of recently added variables first
|
||||
if (loadedWorkflows.has(workflowId)) {
|
||||
// Even if workflow is loaded, check if we have recent variables to protect
|
||||
const workflowVariables = Object.values(get().variables)
|
||||
.filter((v) => v.workflowId === workflowId)
|
||||
|
||||
const now = Date.now()
|
||||
const hasRecentVariables = workflowVariables.some(v =>
|
||||
recentlyAddedVariables.has(v.id) &&
|
||||
(now - (recentlyAddedVariables.get(v.id) || 0) < RECENT_VARIABLE_WINDOW)
|
||||
const workflowVariables = Object.values(get().variables).filter(
|
||||
(v) => v.workflowId === workflowId
|
||||
)
|
||||
|
||||
|
||||
const now = Date.now()
|
||||
const hasRecentVariables = workflowVariables.some(
|
||||
(v) =>
|
||||
recentlyAddedVariables.has(v.id) &&
|
||||
now - (recentlyAddedVariables.get(v.id) || 0) < RECENT_VARIABLE_WINDOW
|
||||
)
|
||||
|
||||
// No force reload needed if no recent variables and we've already loaded
|
||||
if (!hasRecentVariables) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Otherwise continue and do a full load+merge to protect recent variables
|
||||
}
|
||||
|
||||
@@ -438,23 +440,28 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
// Capture current variables for this workflow before we modify anything
|
||||
const currentWorkflowVariables = Object.values(get().variables)
|
||||
.filter((v) => v.workflowId === workflowId)
|
||||
.reduce((acc, v) => {
|
||||
acc[v.id] = v
|
||||
return acc
|
||||
}, {} as Record<string, Variable>)
|
||||
|
||||
.reduce(
|
||||
(acc, v) => {
|
||||
acc[v.id] = v
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Variable>
|
||||
)
|
||||
|
||||
// Check which variables were recently added (within the last few seconds)
|
||||
const now = Date.now()
|
||||
const protectedVariableIds = new Set<string>()
|
||||
|
||||
|
||||
// Identify variables that should be protected from being overwritten
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
Object.keys(currentWorkflowVariables).forEach((id) => {
|
||||
// Protect recently added variables
|
||||
if (recentlyAddedVariables.has(id) &&
|
||||
(now - (recentlyAddedVariables.get(id) || 0) < RECENT_VARIABLE_WINDOW)) {
|
||||
if (
|
||||
recentlyAddedVariables.has(id) &&
|
||||
now - (recentlyAddedVariables.get(id) || 0) < RECENT_VARIABLE_WINDOW
|
||||
) {
|
||||
protectedVariableIds.add(id)
|
||||
}
|
||||
|
||||
|
||||
// Also protect variables that are currently being edited (have pending changes)
|
||||
if (saveTimers.has(workflowId)) {
|
||||
protectedVariableIds.add(id)
|
||||
@@ -475,9 +482,9 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
},
|
||||
{} as Record<string, Variable>
|
||||
)
|
||||
|
||||
|
||||
// Add back protected variables that should not be removed
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
Object.keys(currentWorkflowVariables).forEach((id) => {
|
||||
if (protectedVariableIds.has(id)) {
|
||||
otherVariables[id] = currentWorkflowVariables[id]
|
||||
}
|
||||
@@ -545,12 +552,12 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
},
|
||||
{} as Record<string, Variable>
|
||||
)
|
||||
|
||||
|
||||
// Create the final variables object, prioritizing protected variables
|
||||
const finalVariables = { ...otherVariables, ...migratedData }
|
||||
|
||||
|
||||
// Restore any protected variables that shouldn't be overwritten
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
Object.keys(currentWorkflowVariables).forEach((id) => {
|
||||
if (protectedVariableIds.has(id)) {
|
||||
finalVariables[id] = currentWorkflowVariables[id]
|
||||
}
|
||||
@@ -576,9 +583,9 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
},
|
||||
{} as Record<string, Variable>
|
||||
)
|
||||
|
||||
|
||||
// Add back protected variables that should not be removed
|
||||
Object.keys(currentWorkflowVariables).forEach(id => {
|
||||
Object.keys(currentWorkflowVariables).forEach((id) => {
|
||||
if (protectedVariableIds.has(id)) {
|
||||
otherVariables[id] = currentWorkflowVariables[id]
|
||||
}
|
||||
@@ -617,9 +624,9 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
const workflowVariables = Object.values(get().variables).filter(
|
||||
(variable) => variable.workflowId === workflowId
|
||||
)
|
||||
|
||||
|
||||
// Record the last save attempt timestamp for each variable to track sync state
|
||||
workflowVariables.forEach(variable => {
|
||||
workflowVariables.forEach((variable) => {
|
||||
// Mark save attempt time for all variables being saved
|
||||
recentlyAddedVariables.set(variable.id, Date.now())
|
||||
})
|
||||
@@ -671,7 +678,7 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
// Reset the loaded workflow tracking
|
||||
resetLoaded: () => {
|
||||
loadedWorkflows.clear()
|
||||
|
||||
|
||||
// Clean up stale entries from recentlyAddedVariables
|
||||
const now = Date.now()
|
||||
recentlyAddedVariables.forEach((timestamp, id) => {
|
||||
|
||||
@@ -40,4 +40,4 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
name: 'sidebar-state',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface SyncConfig {
|
||||
syncInterval?: number
|
||||
onSyncSuccess?: (response: any) => void
|
||||
onSyncError?: (error: any) => void
|
||||
|
||||
|
||||
// Enhanced retry configuration
|
||||
maxRetries?: number
|
||||
retryBackoff?: number
|
||||
@@ -61,29 +61,29 @@ export interface SyncOperations {
|
||||
const syncState = {
|
||||
inProgress: new Map<string, boolean>(),
|
||||
lastSyncTime: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
|
||||
// Returns true if a particular endpoint is currently syncing
|
||||
export function isSyncing(endpoint: string): boolean {
|
||||
return syncState.inProgress.get(endpoint) === true;
|
||||
return syncState.inProgress.get(endpoint) === true
|
||||
}
|
||||
|
||||
// Returns the timestamp of the last successful sync for an endpoint
|
||||
export function getLastSyncTime(endpoint: string): number | undefined {
|
||||
return syncState.lastSyncTime.get(endpoint);
|
||||
return syncState.lastSyncTime.get(endpoint)
|
||||
}
|
||||
|
||||
// Performs sync operation with automatic retry
|
||||
export async function performSync(config: SyncConfig): Promise<boolean> {
|
||||
// Skip if sync already in progress for this endpoint
|
||||
if (syncState.inProgress.get(config.endpoint)) {
|
||||
logger.info(`Sync skipped - already in progress for ${config.endpoint}`);
|
||||
return true;
|
||||
logger.info(`Sync skipped - already in progress for ${config.endpoint}`)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Mark sync as in progress
|
||||
syncState.inProgress.set(config.endpoint, true);
|
||||
|
||||
syncState.inProgress.set(config.endpoint, true)
|
||||
|
||||
try {
|
||||
// In localStorage mode, just return success immediately - no need to sync to server
|
||||
if (isLocalStorageMode()) {
|
||||
@@ -94,9 +94,9 @@ export async function performSync(config: SyncConfig): Promise<boolean> {
|
||||
message: 'Skipped sync in localStorage mode',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Update last sync time
|
||||
syncState.lastSyncTime.set(config.endpoint, Date.now());
|
||||
syncState.lastSyncTime.set(config.endpoint, Date.now())
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -106,18 +106,18 @@ export async function performSync(config: SyncConfig): Promise<boolean> {
|
||||
// Skip sync if the payload indicates it should be skipped
|
||||
if (payload && payload.skipSync === true) {
|
||||
// Release lock and return success
|
||||
syncState.inProgress.set(config.endpoint, false);
|
||||
syncState.inProgress.set(config.endpoint, false)
|
||||
return true
|
||||
}
|
||||
|
||||
// Normal API sync flow with retries
|
||||
const result = await sendWithRetry(config.endpoint, payload, config)
|
||||
|
||||
|
||||
// If successful, update last sync time
|
||||
if (result) {
|
||||
syncState.lastSyncTime.set(config.endpoint, Date.now());
|
||||
syncState.lastSyncTime.set(config.endpoint, Date.now())
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (config.onSyncError) {
|
||||
@@ -127,62 +127,64 @@ export async function performSync(config: SyncConfig): Promise<boolean> {
|
||||
return false
|
||||
} finally {
|
||||
// Always release the lock when done
|
||||
syncState.inProgress.set(config.endpoint, false);
|
||||
syncState.inProgress.set(config.endpoint, false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sends data to endpoint with configurable retries
|
||||
async function sendWithRetry(endpoint: string, payload: any, config: SyncConfig): Promise<boolean> {
|
||||
const maxRetries = config.maxRetries || DEFAULT_SYNC_CONFIG.maxRetries || 3;
|
||||
const baseBackoff = config.retryBackoff || DEFAULT_SYNC_CONFIG.retryBackoff || 1000;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
const maxRetries = config.maxRetries || DEFAULT_SYNC_CONFIG.maxRetries || 3
|
||||
const baseBackoff = config.retryBackoff || DEFAULT_SYNC_CONFIG.retryBackoff || 1000
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const result = await sendRequest(endpoint, payload, config);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
const startTime = Date.now()
|
||||
const result = await sendRequest(endpoint, payload, config)
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
if (result) {
|
||||
// Only log retries if they happened
|
||||
if (attempt > 0) {
|
||||
logger.info(`Sync succeeded on attempt ${attempt + 1} for ${endpoint} after ${elapsed}ms`);
|
||||
logger.info(`Sync succeeded on attempt ${attempt + 1} for ${endpoint} after ${elapsed}ms`)
|
||||
}
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
// Calculate exponential backoff with jitter
|
||||
const jitter = Math.random() * 0.3 + 0.85; // Random between 0.85 and 1.15
|
||||
const backoff = baseBackoff * Math.pow(2, attempt) * jitter;
|
||||
|
||||
logger.warn(`Sync attempt ${attempt + 1}/${maxRetries} failed for ${endpoint}. Retrying in ${Math.round(backoff)}ms: ${lastError.message}`);
|
||||
|
||||
const jitter = Math.random() * 0.3 + 0.85 // Random between 0.85 and 1.15
|
||||
const backoff = baseBackoff * Math.pow(2, attempt) * jitter
|
||||
|
||||
logger.warn(
|
||||
`Sync attempt ${attempt + 1}/${maxRetries} failed for ${endpoint}. Retrying in ${Math.round(backoff)}ms: ${lastError.message}`
|
||||
)
|
||||
|
||||
// Only wait if we're going to retry
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, backoff));
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If we got here, all retries failed
|
||||
if (lastError) {
|
||||
if (config.onSyncError) {
|
||||
config.onSyncError(lastError);
|
||||
config.onSyncError(lastError)
|
||||
}
|
||||
logger.error(`All ${maxRetries} sync attempts failed for ${endpoint}: ${lastError.message}`);
|
||||
logger.error(`All ${maxRetries} sync attempts failed for ${endpoint}: ${lastError.message}`)
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Sends a single request to the endpoint
|
||||
async function sendRequest(endpoint: string, payload: any, config: SyncConfig): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: config.method || 'POST',
|
||||
@@ -191,27 +193,27 @@ async function sendRequest(endpoint: string, payload: any, config: SyncConfig):
|
||||
signal: controller.signal,
|
||||
// Add cache control for GET requests to prevent caching
|
||||
cache: config.method === 'GET' ? 'no-store' : undefined,
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Failed to read error response');
|
||||
throw new Error(`Sync failed: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
const errorText = await response.text().catch(() => 'Failed to read error response')
|
||||
throw new Error(`Sync failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
|
||||
if (config.onSyncSuccess) {
|
||||
config.onSyncSuccess(data);
|
||||
config.onSyncSuccess(data)
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
} catch (error) {
|
||||
// Handle abort (timeout) explicitly
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error(`Sync request timed out after 30 seconds`);
|
||||
throw new Error(`Sync request timed out after 30 seconds`)
|
||||
}
|
||||
throw error;
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { SyncManager } from './sync'
|
||||
import { isLocalStorageMode } from './sync-core'
|
||||
import {
|
||||
fetchWorkflowsFromDB,
|
||||
workflowSync,
|
||||
isRegistryInitialized,
|
||||
resetRegistryInitialization
|
||||
import {
|
||||
fetchWorkflowsFromDB,
|
||||
isRegistryInitialized,
|
||||
resetRegistryInitialization,
|
||||
workflowSync,
|
||||
} from './workflows/sync'
|
||||
|
||||
const logger = createLogger('SyncRegistry')
|
||||
@@ -46,24 +46,24 @@ export async function initializeSyncManagers(): Promise<boolean> {
|
||||
managers = [workflowSync]
|
||||
|
||||
// Reset registry initialization state before fetching
|
||||
resetRegistryInitialization();
|
||||
resetRegistryInitialization()
|
||||
|
||||
// Fetch data from DB
|
||||
try {
|
||||
// Remove environment variables fetch
|
||||
await fetchWorkflowsFromDB()
|
||||
|
||||
|
||||
// Wait for a short period to ensure registry is properly initialized
|
||||
if (!isRegistryInitialized()) {
|
||||
logger.info('Waiting for registry initialization to complete...');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
logger.info('Waiting for registry initialization to complete...')
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
|
||||
// Verify initialization complete
|
||||
if (!isRegistryInitialized()) {
|
||||
logger.warn('Registry initialization may not have completed properly');
|
||||
logger.warn('Registry initialization may not have completed properly')
|
||||
} else {
|
||||
logger.info('Registry initialization verified');
|
||||
logger.info('Registry initialization verified')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching data from DB:', { error })
|
||||
|
||||
@@ -147,7 +147,7 @@ export function getAllWorkflowsWithValues() {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -156,9 +156,9 @@ export function getAllWorkflowsWithValues() {
|
||||
* This is a shortcut for other files to trigger sync operations
|
||||
*/
|
||||
export function syncWorkflows() {
|
||||
const workflowStore = useWorkflowStore.getState();
|
||||
workflowStore.sync.markDirty();
|
||||
workflowStore.sync.forceSync();
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
workflowStore.sync.markDirty()
|
||||
workflowStore.sync.forceSync()
|
||||
}
|
||||
|
||||
export { useWorkflowRegistry, useWorkflowStore, useSubBlockStore }
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
saveWorkflowState,
|
||||
} from '../persistence'
|
||||
import { useSubBlockStore } from '../subblock/store'
|
||||
import {
|
||||
fetchWorkflowsFromDB,
|
||||
workflowSync,
|
||||
import {
|
||||
fetchWorkflowsFromDB,
|
||||
markWorkflowsDirty,
|
||||
resetRegistryInitialization,
|
||||
markWorkflowsDirty
|
||||
workflowSync,
|
||||
} from '../sync'
|
||||
import { useWorkflowStore } from '../workflow/store'
|
||||
import { WorkflowMetadata, WorkflowRegistry } from './types'
|
||||
@@ -27,8 +27,8 @@ const logger = createLogger('WorkflowRegistry')
|
||||
const ACTIVE_WORKSPACE_KEY = 'active-workspace-id'
|
||||
|
||||
// Track workspace transitions to prevent race conditions
|
||||
let isWorkspaceTransitioning = false;
|
||||
const TRANSITION_TIMEOUT = 5000; // 5 seconds maximum for workspace transitions
|
||||
let isWorkspaceTransitioning = false
|
||||
const TRANSITION_TIMEOUT = 5000 // 5 seconds maximum for workspace transitions
|
||||
|
||||
// Helps clean up any localStorage data that isn't needed for the current workspace
|
||||
function cleanupLocalStorageForWorkspace(workspaceId: string): void {
|
||||
@@ -164,16 +164,16 @@ function resetWorkflowStores() {
|
||||
* @param isTransitioning Whether workspace is currently transitioning
|
||||
*/
|
||||
function setWorkspaceTransitioning(isTransitioning: boolean): void {
|
||||
isWorkspaceTransitioning = isTransitioning;
|
||||
|
||||
isWorkspaceTransitioning = isTransitioning
|
||||
|
||||
// Set a safety timeout to prevent permanently stuck in transition state
|
||||
if (isTransitioning) {
|
||||
setTimeout(() => {
|
||||
if (isWorkspaceTransitioning) {
|
||||
logger.warn('Forcing workspace transition to complete due to timeout');
|
||||
isWorkspaceTransitioning = false;
|
||||
logger.warn('Forcing workspace transition to complete due to timeout')
|
||||
isWorkspaceTransitioning = false
|
||||
}
|
||||
}, TRANSITION_TIMEOUT);
|
||||
}, TRANSITION_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void {
|
||||
* @returns True if workspace is transitioning
|
||||
*/
|
||||
export function isWorkspaceInTransition(): boolean {
|
||||
return isWorkspaceTransitioning;
|
||||
return isWorkspaceTransitioning
|
||||
}
|
||||
|
||||
export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
@@ -214,15 +214,15 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}
|
||||
|
||||
// Set transition state
|
||||
setWorkspaceTransitioning(true);
|
||||
|
||||
setWorkspaceTransitioning(true)
|
||||
|
||||
logger.info(`Switching from deleted workspace ${currentWorkspaceId} to ${newWorkspaceId}`)
|
||||
|
||||
// Reset all workflow state
|
||||
resetWorkflowStores()
|
||||
|
||||
|
||||
// Reset registry initialization state
|
||||
resetRegistryInitialization();
|
||||
resetRegistryInitialization()
|
||||
|
||||
// Save to localStorage for persistence
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -244,9 +244,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
|
||||
// Clean up any stale localStorage data
|
||||
cleanupLocalStorageForWorkspace(newWorkspaceId)
|
||||
|
||||
|
||||
// End transition state
|
||||
setWorkspaceTransitioning(false);
|
||||
setWorkspaceTransitioning(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error fetching workflows after workspace deletion:', {
|
||||
@@ -254,9 +254,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
workspaceId: newWorkspaceId,
|
||||
})
|
||||
set({ isLoading: false, error: 'Failed to load workspace data' })
|
||||
|
||||
|
||||
// End transition state even on error
|
||||
setWorkspaceTransitioning(false);
|
||||
setWorkspaceTransitioning(false)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -268,23 +268,23 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
if (id === currentWorkspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Prevent multiple workspace transitions at once
|
||||
if (isWorkspaceTransitioning) {
|
||||
logger.warn('Workspace already transitioning, ignoring new request');
|
||||
return;
|
||||
logger.warn('Workspace already transitioning, ignoring new request')
|
||||
return
|
||||
}
|
||||
|
||||
// Set transition state
|
||||
setWorkspaceTransitioning(true);
|
||||
setWorkspaceTransitioning(true)
|
||||
|
||||
logger.info(`Switching workspace from ${currentWorkspaceId} to ${id}`)
|
||||
|
||||
// Reset all workflow state
|
||||
resetWorkflowStores()
|
||||
|
||||
|
||||
// Reset registry initialization state
|
||||
resetRegistryInitialization();
|
||||
resetRegistryInitialization()
|
||||
|
||||
// Save to localStorage for persistence
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -309,16 +309,16 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
|
||||
// Clean up any stale localStorage data for this workspace
|
||||
cleanupLocalStorageForWorkspace(id)
|
||||
|
||||
|
||||
// End transition state
|
||||
setWorkspaceTransitioning(false);
|
||||
setWorkspaceTransitioning(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error fetching workflows for workspace:', { error, workspaceId: id })
|
||||
set({ isLoading: false, error: 'Failed to load workspace data' })
|
||||
|
||||
|
||||
// End transition state even on error
|
||||
setWorkspaceTransitioning(false);
|
||||
setWorkspaceTransitioning(false)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -638,8 +638,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}
|
||||
|
||||
// Mark as dirty to ensure sync
|
||||
useWorkflowStore.getState().sync.markDirty();
|
||||
|
||||
useWorkflowStore.getState().sync.markDirty()
|
||||
|
||||
// Trigger sync
|
||||
useWorkflowStore.getState().sync.forceSync()
|
||||
|
||||
@@ -721,8 +721,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}
|
||||
|
||||
// Mark as dirty to ensure sync
|
||||
useWorkflowStore.getState().sync.markDirty();
|
||||
|
||||
useWorkflowStore.getState().sync.markDirty()
|
||||
|
||||
// Trigger sync
|
||||
useWorkflowStore.getState().sync.forceSync()
|
||||
|
||||
@@ -755,7 +755,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}
|
||||
|
||||
// Get the workspace ID from the source workflow or fall back to active workspace
|
||||
const workspaceId = sourceWorkflow.workspaceId || (activeWorkspaceId || undefined)
|
||||
const workspaceId = sourceWorkflow.workspaceId || activeWorkspaceId || undefined
|
||||
|
||||
// Generate new workflow metadata
|
||||
const newWorkflow: WorkflowMetadata = {
|
||||
@@ -827,12 +827,14 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}
|
||||
|
||||
// Mark as dirty to ensure sync
|
||||
useWorkflowStore.getState().sync.markDirty();
|
||||
|
||||
useWorkflowStore.getState().sync.markDirty()
|
||||
|
||||
// Trigger sync
|
||||
useWorkflowStore.getState().sync.forceSync()
|
||||
|
||||
logger.info(`Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId || 'none'}`)
|
||||
logger.info(
|
||||
`Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId || 'none'}`
|
||||
)
|
||||
|
||||
return id
|
||||
},
|
||||
@@ -862,8 +864,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
})
|
||||
|
||||
// Mark as dirty to ensure sync
|
||||
useWorkflowStore.getState().sync.markDirty();
|
||||
|
||||
useWorkflowStore.getState().sync.markDirty()
|
||||
|
||||
// Sync deletion with database
|
||||
useWorkflowStore.getState().sync.forceSync()
|
||||
|
||||
@@ -956,8 +958,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
saveRegistry(updatedWorkflows)
|
||||
|
||||
// Mark as dirty to ensure sync
|
||||
useWorkflowStore.getState().sync.markDirty();
|
||||
|
||||
useWorkflowStore.getState().sync.markDirty()
|
||||
|
||||
// Use PUT for workflow updates
|
||||
useWorkflowStore.getState().sync.forceSync()
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const LOADING_TIMEOUT = 3000 // 3 seconds maximum loading time
|
||||
|
||||
// Add registry initialization tracking
|
||||
let registryFullyInitialized = false
|
||||
const REGISTRY_INIT_TIMEOUT = 10000; // 10 seconds maximum for registry initialization
|
||||
const REGISTRY_INIT_TIMEOUT = 10000 // 10 seconds maximum for registry initialization
|
||||
|
||||
/**
|
||||
* Checks if the system is currently in the process of loading data from the database
|
||||
@@ -50,7 +50,7 @@ export function isActivelyLoadingFromDB(): boolean {
|
||||
* @returns true if registry is initialized, false otherwise
|
||||
*/
|
||||
export function isRegistryInitialized(): boolean {
|
||||
return registryFullyInitialized;
|
||||
return registryFullyInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,21 +58,21 @@ export function isRegistryInitialized(): boolean {
|
||||
* Should be called only after all workflows have been loaded from DB
|
||||
*/
|
||||
function setRegistryInitialized(): void {
|
||||
registryFullyInitialized = true;
|
||||
logger.info('Workflow registry fully initialized');
|
||||
registryFullyInitialized = true
|
||||
logger.info('Workflow registry fully initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset registry initialization state when needed (e.g., workspace switch, logout)
|
||||
*/
|
||||
export function resetRegistryInitialization(): void {
|
||||
registryFullyInitialized = false;
|
||||
logger.info('Workflow registry initialization reset');
|
||||
registryFullyInitialized = false
|
||||
logger.info('Workflow registry initialization reset')
|
||||
}
|
||||
|
||||
// Enhanced workflow state tracking
|
||||
let lastWorkflowState: Record<string, any> = {};
|
||||
let isDirty = false;
|
||||
let lastWorkflowState: Record<string, any> = {}
|
||||
let isDirty = false
|
||||
|
||||
/**
|
||||
* Checks if workflow state has actually changed since last sync
|
||||
@@ -81,43 +81,46 @@ let isDirty = false;
|
||||
*/
|
||||
function hasWorkflowChanges(currentState: Record<string, any>): boolean {
|
||||
if (!currentState || Object.keys(currentState).length === 0) {
|
||||
return false; // Empty state should not trigger sync
|
||||
return false // Empty state should not trigger sync
|
||||
}
|
||||
|
||||
|
||||
if (Object.keys(lastWorkflowState).length === 0) {
|
||||
// First time check, mark as changed
|
||||
lastWorkflowState = JSON.parse(JSON.stringify(currentState));
|
||||
return true;
|
||||
lastWorkflowState = JSON.parse(JSON.stringify(currentState))
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Check if workflow count changed
|
||||
if (Object.keys(currentState).length !== Object.keys(lastWorkflowState).length) {
|
||||
lastWorkflowState = JSON.parse(JSON.stringify(currentState));
|
||||
return true;
|
||||
lastWorkflowState = JSON.parse(JSON.stringify(currentState))
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Deep comparison of workflow states
|
||||
let hasChanges = false;
|
||||
let hasChanges = false
|
||||
for (const [id, workflow] of Object.entries(currentState)) {
|
||||
if (!lastWorkflowState[id] || JSON.stringify(workflow) !== JSON.stringify(lastWorkflowState[id])) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
if (
|
||||
!lastWorkflowState[id] ||
|
||||
JSON.stringify(workflow) !== JSON.stringify(lastWorkflowState[id])
|
||||
) {
|
||||
hasChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (hasChanges) {
|
||||
lastWorkflowState = JSON.parse(JSON.stringify(currentState));
|
||||
lastWorkflowState = JSON.parse(JSON.stringify(currentState))
|
||||
}
|
||||
|
||||
return hasChanges;
|
||||
|
||||
return hasChanges
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark workflows as dirty (changed) to force a sync
|
||||
*/
|
||||
export function markWorkflowsDirty(): void {
|
||||
isDirty = true;
|
||||
logger.info('Workflows marked as dirty, will sync on next opportunity');
|
||||
isDirty = true
|
||||
logger.info('Workflows marked as dirty, will sync on next opportunity')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,14 +128,14 @@ export function markWorkflowsDirty(): void {
|
||||
* @returns true if workflows are dirty and need syncing
|
||||
*/
|
||||
export function areWorkflowsDirty(): boolean {
|
||||
return isDirty;
|
||||
return isDirty
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the dirty flag after a successful sync
|
||||
*/
|
||||
export function resetDirtyFlag(): void {
|
||||
isDirty = false;
|
||||
isDirty = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,8 +147,8 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
|
||||
|
||||
try {
|
||||
// Reset registry initialization state
|
||||
resetRegistryInitialization();
|
||||
|
||||
resetRegistryInitialization()
|
||||
|
||||
// Set loading state in registry
|
||||
useWorkflowRegistry.getState().setLoading(true)
|
||||
|
||||
@@ -210,9 +213,9 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
|
||||
)
|
||||
// Clear any existing workflows to ensure a clean state
|
||||
useWorkflowRegistry.setState({ workflows: {} })
|
||||
|
||||
|
||||
// Mark registry as initialized even with empty data
|
||||
setRegistryInitialized();
|
||||
setRegistryInitialized()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -321,7 +324,7 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
|
||||
useWorkflowRegistry.setState({ workflows: registryWorkflows })
|
||||
|
||||
// Capture initial state for change detection
|
||||
lastWorkflowState = getAllWorkflowsWithValues();
|
||||
lastWorkflowState = getAllWorkflowsWithValues()
|
||||
|
||||
// 9. Set the first workflow as active if there's no active workflow
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
@@ -337,14 +340,14 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
|
||||
logger.info(`Set first workflow ${firstWorkflowId} as active`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Mark registry as fully initialized now that all data is loaded
|
||||
setRegistryInitialized();
|
||||
setRegistryInitialized()
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workflows from DB:', { error })
|
||||
|
||||
|
||||
// Mark registry as initialized even on error to allow fallback mechanisms
|
||||
setRegistryInitialized();
|
||||
setRegistryInitialized()
|
||||
} finally {
|
||||
// Reset the flag after a short delay to allow state to settle
|
||||
setTimeout(() => {
|
||||
@@ -365,7 +368,7 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
|
||||
if (workflowCount > 0 && activeWorkflowId && activeDBSyncNeeded()) {
|
||||
// Small delay for state to fully settle before allowing syncs
|
||||
setTimeout(() => {
|
||||
isDirty = true; // Explicitly mark as dirty for first sync
|
||||
isDirty = true // Explicitly mark as dirty for first sync
|
||||
workflowSync.sync()
|
||||
}, 500)
|
||||
}
|
||||
@@ -388,7 +391,7 @@ function activeDBSyncNeeded(): boolean {
|
||||
|
||||
// Add additional checks here if needed for specific workflow changes
|
||||
// For now, we'll simply avoid the automatic sync after load
|
||||
return isDirty;
|
||||
return isDirty
|
||||
}
|
||||
|
||||
// Create the basic sync configuration
|
||||
@@ -399,8 +402,8 @@ const workflowSyncConfig = {
|
||||
|
||||
// Skip sync if registry is not fully initialized yet
|
||||
if (!isRegistryInitialized()) {
|
||||
logger.info('Skipping workflow sync while registry is not fully initialized');
|
||||
return { skipSync: true };
|
||||
logger.info('Skipping workflow sync while registry is not fully initialized')
|
||||
return { skipSync: true }
|
||||
}
|
||||
|
||||
// Skip sync if we're currently loading from DB to prevent overwriting DB data
|
||||
@@ -414,12 +417,12 @@ const workflowSyncConfig = {
|
||||
|
||||
// Only sync if there are actually changes
|
||||
if (!isDirty && !hasWorkflowChanges(allWorkflowsData)) {
|
||||
logger.info('Skipping workflow sync - no changes detected');
|
||||
return { skipSync: true };
|
||||
logger.info('Skipping workflow sync - no changes detected')
|
||||
return { skipSync: true }
|
||||
}
|
||||
|
||||
|
||||
// Reset dirty flag since we're about to sync
|
||||
resetDirtyFlag();
|
||||
resetDirtyFlag()
|
||||
|
||||
// Get the active workspace ID
|
||||
const activeWorkspaceId = useWorkflowRegistry.getState().activeWorkspaceId
|
||||
@@ -486,12 +489,12 @@ export const workflowSync = {
|
||||
sync: () => {
|
||||
// Skip sync if not initialized
|
||||
if (!isRegistryInitialized()) {
|
||||
logger.info('Sync requested but registry not fully initialized yet - delaying');
|
||||
logger.info('Sync requested but registry not fully initialized yet - delaying')
|
||||
// If we're not initialized, mark dirty and check again later
|
||||
isDirty = true;
|
||||
return;
|
||||
isDirty = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Clear any existing timeout
|
||||
if (syncDebounceTimer) {
|
||||
clearTimeout(syncDebounceTimer)
|
||||
|
||||
@@ -37,13 +37,13 @@ const initialState = {
|
||||
// Create a consolidated sync control implementation
|
||||
/**
|
||||
* The SyncControl implementation provides a clean, centralized way to handle workflow syncing.
|
||||
*
|
||||
*
|
||||
* This pattern offers several advantages:
|
||||
* 1. It encapsulates sync logic through a clear, standardized interface
|
||||
* 2. It allows components to mark workflows as dirty without direct dependencies
|
||||
* 3. It prevents race conditions by ensuring changes are properly tracked before syncing
|
||||
* 4. It centralizes sync decisions to avoid redundant or conflicting operations
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* - Call markDirty() when workflow state changes but sync can be deferred
|
||||
* - Call forceSync() when an immediate sync to the server is needed
|
||||
@@ -61,7 +61,7 @@ const createSyncControl = (): SyncControl => ({
|
||||
forceSync: () => {
|
||||
markWorkflowsDirty() // Always mark as dirty before forcing a sync
|
||||
workflowSync.sync()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
|
||||
@@ -49,11 +49,11 @@ export interface WorkflowState {
|
||||
// New interface for sync control
|
||||
export interface SyncControl {
|
||||
// Mark the workflow as changed, requiring sync
|
||||
markDirty: () => void;
|
||||
markDirty: () => void
|
||||
// Check if the workflow has unsaved changes
|
||||
isDirty: () => boolean;
|
||||
isDirty: () => boolean
|
||||
// Immediately trigger a sync
|
||||
forceSync: () => void;
|
||||
forceSync: () => void
|
||||
}
|
||||
|
||||
export interface WorkflowActions {
|
||||
@@ -78,7 +78,7 @@ export interface WorkflowActions {
|
||||
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void
|
||||
setScheduleStatus: (hasActiveSchedule: boolean) => void
|
||||
setWebhookStatus: (hasActiveWebhook: boolean) => void
|
||||
|
||||
|
||||
// Add the sync control methods to the WorkflowActions interface
|
||||
sync: SyncControl
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import { autoblocksPromptManagerTool } from './autoblocks'
|
||||
import { browserUseRunTaskTool } from './browser_use'
|
||||
import { clayPopulateTool } from './clay'
|
||||
import { confluenceRetrieveTool, confluenceUpdateTool } from './confluence'
|
||||
import {
|
||||
discordGetMessagesTool,
|
||||
discordGetServerTool,
|
||||
discordGetUserTool,
|
||||
discordSendMessageTool,
|
||||
} from './discord'
|
||||
import { elevenLabsTtsTool } from './elevenlabs'
|
||||
import { exaAnswerTool, exaFindSimilarLinksTool, exaGetContentsTool, exaSearchTool } from './exa'
|
||||
import { fileParseTool } from './file'
|
||||
@@ -64,7 +70,6 @@ import { visionTool } from './vision'
|
||||
import { whatsappSendMessageTool } from './whatsapp'
|
||||
import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x'
|
||||
import { youtubeSearchTool } from './youtube'
|
||||
import { discordGetMessagesTool, discordGetServerTool, discordGetUserTool, discordSendMessageTool } from './discord'
|
||||
|
||||
// Registry of all available tools
|
||||
export const tools: Record<string, ToolConfig> = {
|
||||
@@ -157,4 +162,4 @@ export const tools: Record<string, ToolConfig> = {
|
||||
discord_get_messages: discordGetMessagesTool,
|
||||
discord_get_server: discordGetServerTool,
|
||||
discord_get_user: discordGetUserTool,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user