Compare commits

..

44 Commits

Author SHA1 Message Date
Waleed
31fdd2be13 v0.5.77: room manager redis migration, tool outputs, ui fixes 2026-01-30 14:57:17 -08:00
Waleed
028bc652c2 v0.5.76: posthog improvements, readme updates 2026-01-29 00:13:19 -08:00
Waleed
c6bf5cd58c v0.5.75: search modal overhaul, helm chart updates, run from block, terminal and visual debugging improvements 2026-01-28 22:54:13 -08:00
Vikhyath Mondreti
11dc18a80d v0.5.74: autolayout improvements, clerk integration, auth enforcements 2026-01-27 20:37:39 -08:00
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
299 changed files with 1787 additions and 3020 deletions

View File

@@ -202,6 +202,7 @@ describe('Knowledge Search Utils', () => {
)
expect(result).toEqual([0.1, 0.2, 0.3])
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -232,6 +233,7 @@ describe('Knowledge Search Utils', () => {
)
expect(result).toEqual([0.1, 0.2, 0.3])
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -260,6 +262,7 @@ describe('Knowledge Search Utils', () => {
expect.any(Object)
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -289,6 +292,7 @@ describe('Knowledge Search Utils', () => {
expect.any(Object)
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -321,6 +325,7 @@ describe('Knowledge Search Utils', () => {
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -341,6 +346,7 @@ describe('Knowledge Search Utils', () => {
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -374,6 +380,7 @@ describe('Knowledge Search Utils', () => {
})
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
@@ -406,6 +413,7 @@ describe('Knowledge Search Utils', () => {
})
)
// Clean up
Object.keys(env).forEach((key) => delete (env as any)[key])
})
})
@@ -419,97 +427,4 @@ describe('Knowledge Search Utils', () => {
expect(result).toEqual({})
})
})
describe('Date Filter Format Handling', () => {
it('should accept date-only format (YYYY-MM-DD) in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'eq',
value: '2024-01-15',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp format in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'eq',
value: '2024-01-15T14:30:00',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp with UTC timezone in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'gte',
value: '2024-01-15T14:30:00Z',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(filter.fieldType).toBe('date')
})
it('should accept ISO 8601 timestamp with timezone offset in structured filters', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'lt',
value: '2024-01-15T14:30:00+05:00',
}
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/)
expect(filter.fieldType).toBe('date')
})
it('should support all date comparison operators', () => {
const operators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']
const validDateValue = '2024-01-15'
for (const operator of operators) {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator,
value: validDateValue,
}
expect(filter.operator).toBe(operator)
}
})
it('should support between operator with date range', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'between',
value: '2024-01-01',
valueTo: '2024-12-31',
}
expect(filter.operator).toBe('between')
expect(filter.value).toBe('2024-01-01')
expect(filter.valueTo).toBe('2024-12-31')
})
it('should support between operator with timestamp range', () => {
const filter = {
tagSlot: 'date1',
fieldType: 'date',
operator: 'between',
value: '2024-01-01T00:00:00',
valueTo: '2024-12-31T23:59:59',
}
expect(filter.operator).toBe('between')
expect(filter.value).toMatch(/T\d{2}:\d{2}:\d{2}$/)
expect(filter.valueTo).toMatch(/T\d{2}:\d{2}:\d{2}$/)
})
})
})

View File

@@ -203,74 +203,39 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
}
}
// Handle date operators - accepts YYYY-MM-DD or ISO 8601 timestamp
// Handle date operators - expects YYYY-MM-DD format from frontend
if (fieldType === 'date') {
const dateStr = String(value)
const dateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/
const datetimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
// Validate format - accept date-only or timestamp
const isDateOnly = dateOnlyRegex.test(dateStr)
const isTimestamp = datetimeRegex.test(dateStr)
if (!isDateOnly && !isTimestamp) {
logger.debug(
`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss`
)
// Validate YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
return null
}
// Use date comparison for date-only values, timestamp comparison for timestamps
const castType = isDateOnly ? '::date' : '::timestamp'
switch (operator) {
case 'eq':
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
return sql`${column}::date = ${dateStr}::date`
case 'neq':
return isDateOnly
? sql`${column}::date != ${dateStr}::date`
: sql`${column}::timestamp != ${dateStr}::timestamp`
return sql`${column}::date != ${dateStr}::date`
case 'gt':
return isDateOnly
? sql`${column}::date > ${dateStr}::date`
: sql`${column}::timestamp > ${dateStr}::timestamp`
return sql`${column}::date > ${dateStr}::date`
case 'gte':
return isDateOnly
? sql`${column}::date >= ${dateStr}::date`
: sql`${column}::timestamp >= ${dateStr}::timestamp`
return sql`${column}::date >= ${dateStr}::date`
case 'lt':
return isDateOnly
? sql`${column}::date < ${dateStr}::date`
: sql`${column}::timestamp < ${dateStr}::timestamp`
return sql`${column}::date < ${dateStr}::date`
case 'lte':
return isDateOnly
? sql`${column}::date <= ${dateStr}::date`
: sql`${column}::timestamp <= ${dateStr}::timestamp`
return sql`${column}::date <= ${dateStr}::date`
case 'between':
if (valueTo !== undefined) {
const dateStrTo = String(valueTo)
const isToDateOnly = dateOnlyRegex.test(dateStrTo)
const isToTimestamp = datetimeRegex.test(dateStrTo)
if (!isToDateOnly && !isToTimestamp) {
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
return sql`${column}::date = ${dateStr}::date`
}
// Use date comparison if both are date-only, otherwise use timestamp
if (isDateOnly && isToDateOnly) {
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
}
return sql`${column}::timestamp >= ${dateStr}::timestamp AND ${column}::timestamp <= ${dateStrTo}::timestamp`
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
}
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
return sql`${column}::date = ${dateStr}::date`
default:
return isDateOnly
? sql`${column}::date = ${dateStr}::date`
: sql`${column}::timestamp = ${dateStr}::timestamp`
return sql`${column}::date = ${dateStr}::date`
}
}

View File

@@ -264,7 +264,7 @@ async function handleToolsCall(
method: 'POST',
headers,
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
signal: AbortSignal.timeout(600000), // 10 minute timeout
signal: AbortSignal.timeout(300000), // 5 minute timeout
})
const executeResult = await response.json()

View File

@@ -146,8 +146,6 @@ export default function PlaygroundPage() {
const [isDarkMode, setIsDarkMode] = useState(false)
const [buttonGroupValue, setButtonGroupValue] = useState('curl')
const [dateValue, setDateValue] = useState('')
const [dateTimeValue, setDateTimeValue] = useState('')
const [dateTimePreset, setDateTimePreset] = useState('2025-01-30T14:30:00')
const [dateRangeStart, setDateRangeStart] = useState('')
const [dateRangeEnd, setDateRangeEnd] = useState('')
const [tagItems, setTagItems] = useState<TagItem[]>([
@@ -710,30 +708,6 @@ export default function PlaygroundPage() {
</div>
<span className='text-[var(--text-secondary)] text-sm'>{dateValue || 'No date'}</span>
</VariantRow>
<VariantRow label='with time (empty)'>
<div className='w-72'>
<DatePicker
value={dateTimeValue}
onChange={setDateTimeValue}
placeholder='Select date and time'
showTime
/>
</div>
<span className='text-[var(--text-secondary)] text-sm'>
{dateTimeValue || 'No value'}
</span>
</VariantRow>
<VariantRow label='with time (preset)'>
<div className='w-72'>
<DatePicker
value={dateTimePreset}
onChange={setDateTimePreset}
placeholder='Select date and time'
showTime
/>
</div>
<span className='text-[var(--text-secondary)] text-sm'>{dateTimePreset}</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-56'>
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />

View File

@@ -25,10 +25,6 @@ interface ChunkContextMenuProps {
* Empty space action (shown when right-clicking on empty space)
*/
onAddChunk?: () => void
/**
* View document tags (shown when right-clicking on empty space)
*/
onViewTags?: () => void
/**
* Whether the chunk is currently enabled
*/
@@ -79,7 +75,6 @@ export function ChunkContextMenu({
onToggleEnabled,
onDelete,
onAddChunk,
onViewTags,
isChunkEnabled = true,
hasChunk,
disableToggleEnabled = false,
@@ -186,29 +181,17 @@ export function ChunkContextMenu({
)}
</>
) : (
<>
{onAddChunk && (
<PopoverItem
disabled={disableAddChunk}
onClick={() => {
onAddChunk()
onClose()
}}
>
Create chunk
</PopoverItem>
)}
{onViewTags && (
<PopoverItem
onClick={() => {
onViewTags()
onClose()
}}
>
View tags
</PopoverItem>
)}
</>
onAddChunk && (
<PopoverItem
disabled={disableAddChunk}
onClick={() => {
onAddChunk()
onClose()
}}
>
Create chunk
</PopoverItem>
)
)}
</PopoverContent>
</Popover>

View File

@@ -3,7 +3,6 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Badge,
Button,
Combobox,
DatePicker,
@@ -385,7 +384,7 @@ export function DocumentTagsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='md'>
<ModalContent size='sm'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Document Tags</span>
@@ -406,9 +405,9 @@ export function DocumentTagsModal({
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
{tag.displayName}
</span>
<Badge variant='type' size='sm'>
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</Badge>
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
{formatValueForDisplay(tag.value, tag.fieldType)}
@@ -420,9 +419,9 @@ export function DocumentTagsModal({
e.stopPropagation()
handleRemoveTag(index)
}}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<Trash className='h-3 w-3' />
</Button>
</div>
</div>
@@ -527,8 +526,7 @@ export function DocumentTagsModal({
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
showTime
placeholder='Select date'
/>
) : (
<Input
@@ -681,8 +679,7 @@ export function DocumentTagsModal({
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
showTime
placeholder='Select date'
/>
) : (
<Input

View File

@@ -641,7 +641,7 @@ export function Document({
variant='default'
className='h-[32px] rounded-[6px]'
>
Document tags
Tags
</Button>
)}
<Tooltip.Root>
@@ -864,7 +864,10 @@ export function Document({
{chunk.chunkIndex}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={chunk.content}
>
<SearchHighlight
text={truncateContent(chunk.content, 150, searchQuery)}
searchQuery={searchQuery}
@@ -1149,7 +1152,6 @@ export function Document({
? () => setIsCreateChunkModalOpen(true)
: undefined
}
onViewTags={() => setShowTagsModal(true)}
disableToggleEnabled={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableAddChunk={!userPermissions.canEdit || documentData?.processingStatus === 'failed'}

View File

@@ -49,7 +49,6 @@ import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowl
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import type { DocumentData } from '@/lib/knowledge/types'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
import {
ActionBar,
AddDocumentsModal,
@@ -368,8 +367,6 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false)
const [documentForTags, setDocumentForTags] = useState<DocumentData | null>(null)
const {
isOpen: isContextMenuOpen,
@@ -528,6 +525,7 @@ export function KnowledgeBase({
const newEnabled = !document.enabled
// Optimistic update
updateDocument(docId, { enabled: newEnabled })
updateDocumentMutation(
@@ -538,6 +536,7 @@ export function KnowledgeBase({
},
{
onError: () => {
// Rollback on error
updateDocument(docId, { enabled: !newEnabled })
},
}
@@ -548,6 +547,7 @@ export function KnowledgeBase({
* Handles retrying a failed document processing
*/
const handleRetryDocument = (docId: string) => {
// Optimistic update
updateDocument(docId, {
processingStatus: 'pending',
processingError: null,
@@ -593,6 +593,7 @@ export function KnowledgeBase({
const currentDoc = documents.find((doc) => doc.id === documentId)
const previousName = currentDoc?.filename
// Optimistic update
updateDocument(documentId, { filename: newName })
return new Promise<void>((resolve, reject) => {
@@ -608,6 +609,7 @@ export function KnowledgeBase({
resolve()
},
onError: (err) => {
// Rollback on error
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
}
@@ -971,7 +973,7 @@ export function KnowledgeBase({
variant='default'
className='h-[32px] rounded-[6px]'
>
Tag definitions
Tags
</Button>
)}
<Tooltip.Root>
@@ -1219,9 +1221,17 @@ export function KnowledgeBase({
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
})()}
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={doc.filename}
>
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{doc.filename}</Tooltip.Content>
</Tooltip.Root>
</div>
</TableCell>
<TableCell className='hidden px-[12px] py-[8px] text-[12px] text-[var(--text-muted)] lg:table-cell'>
@@ -1546,22 +1556,6 @@ export function KnowledgeBase({
/>
)}
{/* Document Tags Modal */}
{documentForTags && (
<DocumentTagsModal
open={showDocumentTagsModal}
onOpenChange={setShowDocumentTagsModal}
knowledgeBaseId={id}
documentId={documentForTags.id}
documentData={documentForTags}
onDocumentUpdate={(updates) => {
Object.entries(updates).forEach(([key, value]) => {
updateDocument(documentForTags.id, { [key]: value })
})
}}
/>
)}
<ActionBar
selectedCount={selectedDocuments.size}
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
@@ -1630,8 +1624,13 @@ export function KnowledgeBase({
onViewTags={
contextMenuDocument && selectedDocuments.size === 1
? () => {
setDocumentForTags(contextMenuDocument)
setShowDocumentTagsModal(true)
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
docName: contextMenuDocument.filename || 'Document',
})
router.push(
`/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`
)
}
: undefined
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -22,12 +22,7 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import {
type TagUsageData,
useCreateTagDefinition,
useDeleteTagDefinition,
useTagUsageQuery,
} from '@/hooks/queries/knowledge'
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
const logger = createLogger('BaseTagsModal')
@@ -38,6 +33,13 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
boolean: 'Boolean',
}
interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
interface DocumentListProps {
documents: Array<{ id: string; name: string; tagValue: string }>
totalCount: number
@@ -89,23 +91,45 @@ interface BaseTagsModalProps {
}
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
const { tagDefinitions: kbTagDefinitions } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const createTagMutation = useCreateTagDefinition()
const deleteTagMutation = useDeleteTagDefinition()
const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(
open ? knowledgeBaseId : null
)
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
})
const fetchTagUsage = useCallback(async () => {
if (!knowledgeBaseId) return
try {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error('Failed to fetch tag usage')
}
const result = await response.json()
if (result.success) {
setTagUsageData(result.data)
}
} catch (error) {
logger.error('Error fetching tag usage:', error)
}
}, [knowledgeBaseId])
useEffect(() => {
if (open) {
fetchTagUsage()
}
}, [open, fetchTagUsage])
const getTagUsage = (tagSlot: string): TagUsageData => {
return (
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
@@ -119,29 +143,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const handleDeleteTagClick = async (tag: TagDefinition) => {
setSelectedTag(tag)
const { data: freshTagUsage } = await refetchTagUsage()
const tagUsage = freshTagUsage?.find((usage) => usage.tagSlot === tag.tagSlot)
const documentCount = tagUsage?.documentCount ?? 0
if (documentCount === 0) {
try {
await deleteTagMutation.mutateAsync({
knowledgeBaseId,
tagDefinitionId: tag.id,
})
setSelectedTag(null)
} catch (error) {
logger.error('Error deleting tag definition:', error)
}
} else {
setDeleteTagDialogOpen(true)
}
await fetchTagUsage()
setDeleteTagDialogOpen(true)
}
const handleViewDocuments = async (tag: TagDefinition) => {
setSelectedTag(tag)
await refetchTagUsage()
await fetchTagUsage()
setViewDocumentsDialogOpen(true)
}
@@ -211,6 +219,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
fieldType: createTagForm.fieldType,
})
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setCreateTagForm({
displayName: '',
fieldType: 'text',
@@ -230,6 +240,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
tagDefinitionId: selectedTag.id,
})
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setDeleteTagDialogOpen(false)
setSelectedTag(null)
} catch (error) {
@@ -253,7 +265,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return (
<>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='md'>
<ModalContent size='sm'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Tags</span>
@@ -303,10 +315,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
e.stopPropagation()
handleDeleteTagClick(tag)
}}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Tag</span>
<Trash className='h-3 w-3' />
</Button>
</div>
</div>
@@ -320,7 +331,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
className='w-full'
>
Add tag definition
Add Tag
</Button>
)}
@@ -404,7 +415,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{/* Delete Tag Confirmation Dialog */}
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
<ModalContent size='md'>
<ModalContent size='sm'>
<ModalHeader>Delete Tag</ModalHeader>
<ModalBody>
<div className='space-y-[8px]'>
@@ -447,7 +458,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{/* View Documents Dialog */}
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
<ModalContent size='md'>
<ModalContent size='sm'>
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody>
<div className='space-y-[8px]'>

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
@@ -15,7 +15,6 @@ import {
import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
const logger = createLogger('KnowledgeHeader')
@@ -56,23 +55,43 @@ interface Workspace {
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const updateKnowledgeBase = useUpdateKnowledgeBase()
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
!!options?.knowledgeBaseId
)
const workspaces = useMemo<Workspace[]>(
() =>
allWorkspaces
.filter((ws) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions as 'admin' | 'write' | 'read',
})),
[allWorkspaces]
)
useEffect(() => {
if (!options?.knowledgeBaseId) return
const fetchWorkspaces = async () => {
try {
setIsLoadingWorkspaces(true)
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))
setWorkspaces(availableWorkspaces)
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsLoadingWorkspaces(false)
}
}
fetchWorkspaces()
}, [options?.knowledgeBaseId])
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return

View File

@@ -32,7 +32,8 @@ import {
useTestNotification,
useUpdateNotification,
} from '@/hooks/queries/notifications'
import { useConnectedAccounts, useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { SlackChannelSelector } from './components/slack-channel-selector'
import { WorkflowSelector } from './components/workflow-selector'
@@ -166,8 +167,7 @@ export function NotificationSettings({
const deleteNotification = useDeleteNotification()
const testNotification = useTestNotification()
const { data: slackAccounts = [], isLoading: isLoadingSlackAccounts } =
useConnectedAccounts('slack')
const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts()
const connectSlack = useConnectOAuthService()
useEffect(() => {
@@ -530,7 +530,7 @@ export function NotificationSettings({
message:
result.data?.error || (result.data?.success ? 'Test sent successfully' : 'Test failed'),
})
} catch (_error) {
} catch (error) {
setTestStatus({ id, success: false, message: 'Failed to send test' })
}
}

View File

@@ -1,28 +1,32 @@
'use client'
import type React from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import {
useWorkspacePermissionsQuery,
type WorkspacePermissions,
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import {
useWorkspacePermissions,
type WorkspacePermissions,
} from '@/hooks/use-workspace-permissions'
import { useNotificationStore } from '@/stores/notifications'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('WorkspacePermissionsProvider')
interface WorkspacePermissionsContextType {
// Raw workspace permissions data
workspacePermissions: WorkspacePermissions | null
permissionsLoading: boolean
permissionsError: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetchPermissions: () => Promise<void>
// Computed user permissions (connection-aware)
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
// Connection state management
setOfflineMode: (isOffline: boolean) => void
}
const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextType>({
@@ -39,6 +43,7 @@ const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextTyp
isLoading: false,
error: null,
},
setOfflineMode: () => {},
})
interface WorkspacePermissionsProviderProps {
@@ -46,20 +51,35 @@ interface WorkspacePermissionsProviderProps {
}
/**
* Provides workspace permissions and connection-aware user access throughout the app.
* Enforces read-only mode when offline to prevent data loss.
* Provider that manages workspace permissions and user access
* Also provides connection-aware permissions that enforce read-only mode when offline
*/
export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsProviderProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const queryClient = useQueryClient()
// Manage offline mode state locally
const [isOfflineMode, setIsOfflineMode] = useState(false)
// Track whether we've already surfaced an offline notification to avoid duplicates
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
const addNotification = useNotificationStore((state) => state.addNotification)
const isOfflineMode = hasOperationError
// Set offline mode when there are operation errors
useEffect(() => {
if (hasOperationError) {
setIsOfflineMode(true)
}
}, [hasOperationError])
/**
* Surface a global notification when entering offline mode.
* Uses the shared notifications system instead of bespoke UI in individual components.
*/
useEffect(() => {
if (!isOfflineMode || hasShownOfflineNotification) {
return
@@ -69,6 +89,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
addNotification({
level: 'error',
message: 'Connection unavailable',
// Global notification (no workflowId) so it is visible regardless of the active workflow
action: {
type: 'refresh',
message: '',
@@ -80,44 +101,40 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
}
}, [addNotification, hasShownOfflineNotification, isOfflineMode])
// Fetch workspace permissions and loading state
const {
data: workspacePermissions,
isLoading: permissionsLoading,
error: permissionsErrorObj,
refetch,
} = useWorkspacePermissionsQuery(workspaceId)
const permissionsError = permissionsErrorObj?.message ?? null
const updatePermissions = useCallback(
(newPermissions: WorkspacePermissions) => {
if (!workspaceId) return
queryClient.setQueryData(workspaceKeys.permissions(workspaceId), newPermissions)
},
[workspaceId, queryClient]
)
const refetchPermissions = useCallback(async () => {
await refetch()
}, [refetch])
permissions: workspacePermissions,
loading: permissionsLoading,
error: permissionsError,
updatePermissions,
refetch: refetchPermissions,
} = useWorkspacePermissions(workspaceId)
// Get base user permissions from workspace permissions
const baseUserPermissions = useUserPermissions(
workspacePermissions ?? null,
workspacePermissions,
permissionsLoading,
permissionsError
)
// Note: Connection-based error detection removed - only rely on operation timeouts
// The 5-second operation timeout system will handle all error cases
// Create connection-aware permissions that override user permissions when offline
const userPermissions = useMemo((): WorkspaceUserPermissions & { isOfflineMode?: boolean } => {
if (isOfflineMode) {
// In offline mode, force read-only permissions regardless of actual user permissions
return {
...baseUserPermissions,
canEdit: false,
canAdmin: false,
// Keep canRead true so users can still view content
canRead: baseUserPermissions.canRead,
isOfflineMode: true,
}
}
// When online, use normal permissions
return {
...baseUserPermissions,
isOfflineMode: false,
@@ -126,12 +143,13 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
const contextValue = useMemo(
() => ({
workspacePermissions: workspacePermissions ?? null,
workspacePermissions,
permissionsLoading,
permissionsError,
updatePermissions,
refetchPermissions,
userPermissions,
setOfflineMode: setIsOfflineMode,
}),
[
workspacePermissions,
@@ -151,8 +169,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
}
/**
* Accesses workspace permissions data and operations from context.
* Must be used within a WorkspacePermissionsProvider.
* Hook to access workspace permissions and data from context
* This provides both raw workspace permissions and computed user permissions
*/
export function useWorkspacePermissionsContext(): WorkspacePermissionsContextType {
const context = useContext(WorkspacePermissionsContext)
@@ -165,8 +183,8 @@ export function useWorkspacePermissionsContext(): WorkspacePermissionsContextTyp
}
/**
* Accesses the current user's computed permissions including offline mode status.
* Convenience hook that extracts userPermissions from the context.
* Hook to access user permissions from context
* This replaces individual useUserPermissions calls and includes connection-aware permissions
*/
export function useUserPermissionsContext(): WorkspaceUserPermissions & {
isOfflineMode?: boolean

View File

@@ -1,11 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { useKnowledgeBasesQuery } from '@/hooks/queries/knowledge'
import { useRecentLogs } from '@/hooks/queries/logs'
import { useTemplates } from '@/hooks/queries/templates'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -110,10 +107,10 @@ export interface MentionDataReturn {
// Ensure loaded functions
ensurePastChatsLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => void
ensureKnowledgeLoaded: () => Promise<void>
ensureBlocksLoaded: () => Promise<void>
ensureTemplatesLoaded: () => void
ensureLogsLoaded: () => void
ensureTemplatesLoaded: () => Promise<void>
ensureLogsLoaded: () => Promise<void>
}
/**
@@ -131,20 +128,8 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
const [pastChats, setPastChats] = useState<PastChat[]>([])
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
const [shouldLoadKnowledge, setShouldLoadKnowledge] = useState(false)
const { data: knowledgeData = [], isLoading: isLoadingKnowledge } = useKnowledgeBasesQuery(
workspaceId,
{ enabled: shouldLoadKnowledge }
)
const knowledgeBases = useMemo<KnowledgeItem[]>(() => {
const sorted = [...knowledgeData].sort((a, b) => {
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
})
return sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))
}, [knowledgeData])
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeItem[]>([])
const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false)
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
@@ -153,39 +138,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
setBlocksList([])
}, [config.allowedIntegrations])
const [shouldLoadTemplates, setShouldLoadTemplates] = useState(false)
const { data: templatesData, isLoading: isLoadingTemplates } = useTemplates(
{ limit: 50, offset: 0 },
{ enabled: shouldLoadTemplates }
)
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
const templatesList = useMemo<TemplateItem[]>(() => {
const items = templatesData?.data ?? []
return items
.map((t) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a, b) => b.stars - a.stars)
}, [templatesData])
const [shouldLoadLogs, setShouldLoadLogs] = useState(false)
const { data: logsData = [], isLoading: isLoadingLogs } = useRecentLogs(workspaceId, 50, {
enabled: shouldLoadLogs,
})
const logsList = useMemo<LogItem[]>(
() =>
logsData.map((l) => ({
id: l.id,
executionId: l.executionId || l.id,
level: l.level,
trigger: l.trigger || null,
createdAt: l.createdAt,
workflowName:
(l.workflow && (l.workflow.name || l.workflow.title)) ||
l.workflowName ||
'Untitled Workflow',
})),
[logsData]
)
const [logsList, setLogsList] = useState<LogItem[]>([])
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
@@ -234,6 +191,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
}
try {
// Fetch current blocks from store
const workflowStoreBlocks = useWorkflowStore.getState().blocks
const { registry: blockRegistry } = await import('@/blocks/registry')
@@ -290,11 +248,25 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
/**
* Ensures knowledge bases are loaded
*/
const ensureKnowledgeLoaded = useCallback(() => {
if (!shouldLoadKnowledge) {
setShouldLoadKnowledge(true)
const ensureKnowledgeLoaded = useCallback(async () => {
if (isLoadingKnowledge || knowledgeBases.length > 0) return
try {
setIsLoadingKnowledge(true)
const resp = await fetch(`/api/knowledge?workspaceId=${workspaceId}`)
if (!resp.ok) throw new Error(`Failed to load knowledge bases: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const sorted = [...items].sort((a: any, b: any) => {
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
return tb - ta
})
setKnowledgeBases(sorted.map((k: any) => ({ id: k.id, name: k.name || 'Untitled' })))
} catch {
} finally {
setIsLoadingKnowledge(false)
}
}, [shouldLoadKnowledge])
}, [isLoadingKnowledge, knowledgeBases.length, workspaceId])
/**
* Ensures blocks are loaded
@@ -347,22 +319,55 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
/**
* Ensures templates are loaded
*/
const ensureTemplatesLoaded = useCallback(() => {
if (!shouldLoadTemplates) {
setShouldLoadTemplates(true)
const ensureTemplatesLoaded = useCallback(async () => {
if (isLoadingTemplates || templatesList.length > 0) return
try {
setIsLoadingTemplates(true)
const resp = await fetch('/api/templates?limit=50&offset=0')
if (!resp.ok) throw new Error(`Failed to load templates: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const mapped = items
.map((t: any) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
.sort((a: any, b: any) => b.stars - a.stars)
setTemplatesList(mapped)
} catch {
} finally {
setIsLoadingTemplates(false)
}
}, [shouldLoadTemplates])
}, [isLoadingTemplates, templatesList.length])
/**
* Ensures logs are loaded
*/
const ensureLogsLoaded = useCallback(() => {
if (!shouldLoadLogs) {
setShouldLoadLogs(true)
const ensureLogsLoaded = useCallback(async () => {
if (isLoadingLogs || logsList.length > 0) return
try {
setIsLoadingLogs(true)
const resp = await fetch(`/api/logs?workspaceId=${workspaceId}&limit=50&details=full`)
if (!resp.ok) throw new Error(`Failed to load logs: ${resp.status}`)
const data = await resp.json()
const items = Array.isArray(data?.data) ? data.data : []
const mapped = items.map((l: any) => ({
id: l.id,
executionId: l.executionId || l.id,
level: l.level,
trigger: l.trigger || null,
createdAt: l.createdAt,
workflowName:
(l.workflow && (l.workflow.name || l.workflow.title)) ||
l.workflowName ||
'Untitled Workflow',
}))
setLogsList(mapped)
} catch {
} finally {
setIsLoadingLogs(false)
}
}, [shouldLoadLogs])
}, [isLoadingLogs, logsList.length, workspaceId])
return {
// State
pastChats,
isLoadingPastChats,
workflows,
@@ -377,6 +382,8 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
isLoadingLogs,
workflowBlocks,
isLoadingWorkflowBlocks,
// Operations
ensurePastChatsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,

View File

@@ -150,9 +150,7 @@ export function Editor() {
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = userPermissions.canEdit
? advancedMode
: advancedMode || advancedValuesPresent
const displayAdvancedOptions = advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {

View File

@@ -21,13 +21,14 @@ import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useDeleteWorkspaceFile,
useStorageInfo,
useUploadWorkspaceFile,
useWorkspaceFiles,
} from '@/hooks/queries/workspace-files'
import { useUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
const logger = createLogger('FileUploadsSettings')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
@@ -93,7 +94,9 @@ export function Files() {
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { userPermissions, permissionsLoading } = useWorkspacePermissionsContext()
const { permissions: workspacePermissions, loading: permissionsLoading } =
useWorkspacePermissions(workspaceId)
const userPermissions = useUserPermissions(workspacePermissions, permissionsLoading)
const handleUploadClick = () => {
fileInputRef.current?.click()

View File

@@ -0,0 +1,23 @@
import React from 'react'
import { Skeleton } from '@/components/ui/skeleton'
export const PermissionsTableSkeleton = React.memo(() => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
</div>
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>
</div>
))
PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton'

View File

@@ -1,39 +1,20 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2, RotateCw, X } from 'lucide-react'
import { Badge, Button, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth/auth-client'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions'
import { PermissionSelector } from './permission-selector'
import { PermissionsTableSkeleton } from './permissions-table-skeleton'
import type { UserPermissions } from './types'
const PermissionsTableSkeleton = () => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
</div>
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>
</div>
)
export interface PermissionsTableProps {
userPermissions: UserPermissions[]
onPermissionChange: (userId: string, permissionType: PermissionType) => void
onRemoveMember?: (userId: string, email: string) => void
onRemoveInvitation?: (invitationId: string, email: string) => void
onResendInvitation?: (invitationId: string) => void
onResendInvitation?: (invitationId: string, email: string) => void
disabled?: boolean
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
isSaving?: boolean
@@ -162,6 +143,7 @@ export const PermissionsTable = ({
<div>
{allUsers.map((user) => {
const isCurrentUser = user.isCurrentUser === true
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
const isPendingInvitation = user.isPendingInvitation === true
const userIdentifier = user.userId || user.email
const originalPermission = workspacePermissions?.users?.find(
@@ -223,7 +205,7 @@ export const PermissionsTable = ({
<span className='inline-flex'>
<Button
variant='ghost'
onClick={() => onResendInvitation(user.invitationId!)}
onClick={() => onResendInvitation(user.invitationId!, user.email)}
disabled={
disabled ||
isSaving ||

View File

@@ -1,4 +1,5 @@
export { PermissionSelector } from './components/permission-selector'
export { PermissionsTable } from './components/permissions-table'
export { PermissionsTableSkeleton } from './components/permissions-table-skeleton'
export type { PermissionType, UserPermissions } from './components/types'
export { InviteModal } from './invite-modal'

View File

@@ -19,14 +19,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { PermissionsTable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table'
import {
useBatchSendWorkspaceInvitations,
useCancelWorkspaceInvitation,
usePendingInvitations,
useRemoveWorkspaceMember,
useResendWorkspaceInvitation,
useUpdateWorkspacePermissions,
} from '@/hooks/queries/invitations'
import { API_ENDPOINTS } from '@/stores/constants'
import type { PermissionType, UserPermissions } from './components/types'
const logger = createLogger('InviteModal')
@@ -37,25 +30,40 @@ interface InviteModalProps {
workspaceName?: string
}
interface PendingInvitation {
id: string
workspaceId: string
email: string
permissions: PermissionType
status: string
createdAt: string
}
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
const [existingUserPermissionChanges, setExistingUserPermissionChanges] = useState<
Record<string, Partial<UserPermissions>>
>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const cooldownIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; email: string } | null>(
null
)
const [isRemovingMember, setIsRemovingMember] = useState(false)
const [invitationToRemove, setInvitationToRemove] = useState<{
invitationId: string
email: string
} | null>(null)
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
const [resentInvitationIds, setResentInvitationIds] = useState<Record<string, boolean>>({})
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -64,26 +72,50 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
workspacePermissions,
permissionsLoading,
updatePermissions,
refetchPermissions,
userPermissions: userPerms,
} = useWorkspacePermissionsContext()
const { data: pendingInvitations = [], isLoading: isPendingInvitationsLoading } =
usePendingInvitations(open ? workspaceId : undefined)
const batchSendInvitations = useBatchSendWorkspaceInvitations()
const cancelInvitation = useCancelWorkspaceInvitation()
const resendInvitation = useResendWorkspaceInvitation()
const removeMember = useRemoveWorkspaceMember()
const updatePermissionsMutation = useUpdateWorkspacePermissions()
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
const validEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
const hasNewInvites = validEmails.length > 0
const isSubmitting = batchSendInvitations.isPending
const isSaving = updatePermissionsMutation.isPending
const isRemovingMember = removeMember.isPending
const isRemovingInvitation = cancelInvitation.isPending
const fetchPendingInvitations = useCallback(async () => {
if (!workspaceId) return
setIsPendingInvitationsLoading(true)
try {
const response = await fetch('/api/workspaces/invitations')
if (response.ok) {
const data = await response.json()
const workspacePendingInvitations =
data.invitations
?.filter(
(inv: PendingInvitation) =>
inv.status === 'pending' && inv.workspaceId === workspaceId
)
.map((inv: PendingInvitation) => ({
email: inv.email,
permissionType: inv.permissions,
isPendingInvitation: true,
invitationId: inv.id,
})) || []
setPendingInvitations(workspacePendingInvitations)
}
} catch (error) {
logger.error('Error fetching pending invitations:', error)
} finally {
setIsPendingInvitationsLoading(false)
}
}, [workspaceId])
useEffect(() => {
if (open && workspaceId) {
fetchPendingInvitations()
refetchPermissions()
}
}, [open, workspaceId, fetchPendingInvitations, refetchPermissions])
useEffect(() => {
if (open) {
@@ -148,12 +180,16 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
[emailItems, pendingInvitations, workspacePermissions?.users, session?.user?.email]
)
const removeEmailItem = useCallback((value: string, index: number, isValid?: boolean) => {
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid) {
setUserPermissions((prev) => prev.filter((user) => user.email !== value))
}
}, [])
const removeEmailItem = useCallback(
(_value: string, index: number, isValid?: boolean) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid ?? itemToRemove?.isValid) {
setUserPermissions((prev) => prev.filter((user) => user.email !== itemToRemove?.value))
}
},
[emailItems]
)
const fileInputOptions: FileInputOptions = useMemo(
() => ({
@@ -162,8 +198,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
extractValues: (text: string) => {
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
const matches = text.match(emailRegex) || []
const uniqueEmails = [...new Set(matches.map((e) => e.toLowerCase()))]
return uniqueEmails.filter((email) => quickValidateEmail(email).isValid)
return [...new Set(matches.map((e) => e.toLowerCase()))]
},
tooltip: 'Upload emails',
}),
@@ -195,38 +230,53 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
[workspacePermissions?.users]
)
const handleSaveChanges = useCallback(() => {
const handleSaveChanges = useCallback(async () => {
if (!userPerms.canAdmin || !hasPendingChanges || !workspaceId) return
setIsSaving(true)
setErrorMessage(null)
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
userId,
permissions: (changes.permissionType || 'read') as 'admin' | 'write' | 'read',
}))
try {
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
userId,
permissions: changes.permissionType || 'read',
}))
updatePermissionsMutation.mutate(
{ workspaceId, updates },
{
onSuccess: (data) => {
if (data.users && data.total !== undefined) {
updatePermissions({ users: data.users, total: data.total })
}
setExistingUserPermissionChanges({})
},
onError: (error) => {
logger.error('Error saving permission changes:', error)
setErrorMessage(error.message || 'Failed to save permission changes. Please try again.')
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(workspaceId), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ updates }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update permissions')
}
)
if (data.users && data.total !== undefined) {
updatePermissions({ users: data.users, total: data.total })
}
setExistingUserPermissionChanges({})
} catch (error) {
logger.error('Error saving permission changes:', error)
const errorMsg =
error instanceof Error
? error.message
: 'Failed to save permission changes. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsSaving(false)
}
}, [
userPerms.canAdmin,
hasPendingChanges,
workspaceId,
existingUserPermissionChanges,
updatePermissions,
updatePermissionsMutation,
])
const handleRestoreChanges = useCallback(() => {
@@ -239,57 +289,62 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setMemberToRemove({ userId, email })
}, [])
const handleRemoveMemberConfirm = useCallback(() => {
const handleRemoveMemberConfirm = useCallback(async () => {
if (!memberToRemove || !workspaceId || !userPerms.canAdmin) return
setIsRemovingMember(true)
setErrorMessage(null)
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
try {
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
if (!userRecord) {
setErrorMessage('User is not a member of this workspace')
setMemberToRemove(null)
return
}
removeMember.mutate(
{ userId: memberToRemove.userId, workspaceId },
{
onSuccess: () => {
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
)
updatePermissions({
users: updatedUsers,
total: workspacePermissions.total - 1,
})
}
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
return updated
})
setMemberToRemove(null)
},
onError: (error) => {
logger.error('Error removing member:', error)
setErrorMessage(error.message || 'Failed to remove member. Please try again.')
setMemberToRemove(null)
},
if (!userRecord) {
throw new Error('User is not a member of this workspace')
}
)
}, [
memberToRemove,
workspaceId,
userPerms.canAdmin,
workspacePermissions,
updatePermissions,
removeMember,
])
const response = await fetch(`/api/workspaces/members/${memberToRemove.userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to remove member')
}
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
)
updatePermissions({
users: updatedUsers,
total: workspacePermissions.total - 1,
})
}
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
return updated
})
} catch (error) {
logger.error('Error removing member:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to remove member. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsRemovingMember(false)
setMemberToRemove(null)
}
}, [memberToRemove, workspaceId, userPerms.canAdmin, workspacePermissions, updatePermissions])
const handleRemoveMemberCancel = useCallback(() => {
setMemberToRemove(null)
@@ -299,101 +354,120 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvitationToRemove({ invitationId, email })
}, [])
const handleRemoveInvitationConfirm = useCallback(() => {
const handleRemoveInvitationConfirm = useCallback(async () => {
if (!invitationToRemove || !workspaceId || !userPerms.canAdmin) return
setIsRemovingInvitation(true)
setErrorMessage(null)
cancelInvitation.mutate(
{ invitationId: invitationToRemove.invitationId, workspaceId },
{
onSuccess: () => {
setInvitationToRemove(null)
},
onError: (error) => {
logger.error('Error cancelling invitation:', error)
setErrorMessage(error.message || 'Failed to cancel invitation. Please try again.')
setInvitationToRemove(null)
},
try {
const response = await fetch(
`/api/workspaces/invitations/${invitationToRemove.invitationId}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
}
)
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to cancel invitation')
}
)
}, [invitationToRemove, workspaceId, userPerms.canAdmin, cancelInvitation])
setPendingInvitations((prev) =>
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
)
} catch (error) {
logger.error('Error cancelling invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to cancel invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsRemovingInvitation(false)
setInvitationToRemove(null)
}
}, [invitationToRemove, workspaceId, userPerms.canAdmin])
const handleRemoveInvitationCancel = useCallback(() => {
setInvitationToRemove(null)
}, [])
const handleResendInvitation = useCallback(
(invitationId: string) => {
async (invitationId: string, email: string) => {
if (!workspaceId || !userPerms.canAdmin) return
const secondsLeft = resendCooldowns[invitationId]
if (secondsLeft && secondsLeft > 0) return
if (resendingInvitationIds[invitationId]) return
setErrorMessage(null)
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setErrorMessage(null)
resendInvitation.mutate(
{ invitationId, workspaceId },
{
onSuccess: () => {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
if (existingInterval) {
clearInterval(existingInterval)
}
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
cooldownIntervalsRef.current.delete(invitationId)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
cooldownIntervalsRef.current.set(invitationId, interval)
},
onError: (error) => {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
logger.error('Error resending invitation:', error)
setErrorMessage(error.message || 'Failed to resend invitation. Please try again.')
try {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to resend invitation')
}
)
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
} catch (error) {
logger.error('Error resending invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to resend invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
if (existingInterval) {
clearInterval(existingInterval)
}
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
cooldownIntervalsRef.current.delete(invitationId)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
cooldownIntervalsRef.current.set(invitationId, interval)
}
},
[workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation]
[workspaceId, userPerms.canAdmin, resendCooldowns]
)
const handleSubmit = useCallback(
(e: React.FormEvent) => {
async (e: React.FormEvent) => {
e.preventDefault()
setErrorMessage(null)
@@ -402,65 +476,122 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
return
}
const invitations = validEmails.map((email) => {
const userPermission = userPermissions.find((up) => up.email === email)
return {
email,
permission: (userPermission?.permissionType || 'read') as 'admin' | 'write' | 'read',
}
})
setIsSubmitting(true)
batchSendInvitations.mutate(
{ workspaceId, invitations },
{
onSuccess: (result) => {
if (result.failed.length > 0) {
setEmailItems(result.failed.map((f) => ({ value: f.email, isValid: true })))
setUserPermissions((prev) =>
prev.filter((user) => result.failed.some((f) => f.email === user.email))
)
setErrorMessage(result.failed[0].error)
} else {
setEmailItems([])
setUserPermissions([])
try {
const failedInvites: string[] = []
const results = await Promise.all(
validEmails.map(async (email) => {
try {
const userPermission = userPermissions.find((up) => up.email === email)
const permissionType = userPermission?.permissionType || 'read'
const response = await fetch('/api/workspaces/invitations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId,
email: email,
role: 'member',
permission: permissionType,
}),
})
const data = await response.json()
if (!response.ok) {
failedInvites.push(email)
if (data.error) {
setErrorMessage(data.error)
}
return false
}
return true
} catch {
failedInvites.push(email)
return false
}
},
onError: (error) => {
logger.error('Error inviting members:', error)
setErrorMessage(error.message || 'An unexpected error occurred. Please try again.')
},
})
)
const successCount = results.filter(Boolean).length
const successfulEmails = validEmails.filter((_, index) => results[index])
if (successCount > 0) {
if (successfulEmails.length > 0) {
const newPendingInvitations: UserPermissions[] = successfulEmails.map((email) => {
const userPermission = userPermissions.find((up) => up.email === email)
const permissionType = userPermission?.permissionType || 'read'
return {
email,
permissionType,
isPendingInvitation: true,
}
})
setPendingInvitations((prev) => {
const existingEmails = new Set(prev.map((inv) => inv.email))
const merged = [...prev]
newPendingInvitations.forEach((inv) => {
if (!existingEmails.has(inv.email)) {
merged.push(inv)
}
})
return merged
})
}
fetchPendingInvitations()
if (failedInvites.length > 0) {
setEmailItems(failedInvites.map((email) => ({ value: email, isValid: true })))
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
} else {
setEmailItems([])
setUserPermissions([])
}
}
)
} catch (err) {
logger.error('Error inviting members:', err)
const errorMessage =
err instanceof Error ? err.message : 'An unexpected error occurred. Please try again.'
setErrorMessage(errorMessage)
} finally {
setIsSubmitting(false)
}
},
[validEmails, workspaceId, userPermissions, batchSendInvitations]
[validEmails, workspaceId, userPermissions, fetchPendingInvitations]
)
const resetState = useCallback(() => {
setEmailItems([])
setUserPermissions([])
setPendingInvitations([])
setIsPendingInvitationsLoading(false)
setExistingUserPermissionChanges({})
setIsSubmitting(false)
setIsSaving(false)
setErrorMessage(null)
setMemberToRemove(null)
setIsRemovingMember(false)
setInvitationToRemove(null)
setIsRemovingInvitation(false)
setResendCooldowns({})
setResentInvitationIds({})
setResendingInvitationIds({})
cooldownIntervalsRef.current.forEach((interval) => clearInterval(interval))
cooldownIntervalsRef.current.clear()
}, [])
const pendingInvitationsForTable: UserPermissions[] = useMemo(
() =>
pendingInvitations.map((inv) => ({
email: inv.email,
permissionType: inv.permissionType,
isPendingInvitation: true,
invitationId: inv.invitationId,
})),
[pendingInvitations]
)
return (
<Modal
open={open}
@@ -550,7 +681,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
isSaving={isSaving}
workspacePermissions={workspacePermissions}
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitationsForTable}
pendingInvitations={pendingInvitations}
isPendingInvitationsLoading={isPendingInvitationsLoading}
resendingInvitationIds={resendingInvitationIds}
resentInvitationIds={resentInvitationIds}
@@ -560,29 +691,26 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
</ModalBody>
<ModalFooter className='justify-between'>
<div
className={`flex gap-[8px] ${hasPendingChanges && userPerms.canAdmin ? '' : 'pointer-events-none invisible'}`}
aria-hidden={!(hasPendingChanges && userPerms.canAdmin)}
>
<Button
type='button'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
>
Restore Changes
</Button>
<Button
type='button'
variant='tertiary'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
{hasPendingChanges && userPerms.canAdmin && (
<div className='flex gap-[8px]'>
<Button
type='button'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
>
Restore Changes
</Button>
<Button
type='button'
variant='tertiary'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
<Button
type='button'

View File

@@ -14,4 +14,4 @@ export {
export { useSidebarResize } from './use-sidebar-resize'
export { useWorkflowOperations } from './use-workflow-operations'
export { useWorkflowSelection } from './use-workflow-selection'
export { useWorkspaceManagement, type Workspace } from './use-workspace-management'
export { useWorkspaceManagement } from './use-workspace-management'

View File

@@ -1,33 +1,31 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import { generateWorkspaceName } from '@/lib/workspaces/naming'
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
import {
useCreateWorkspace,
useDeleteWorkspace,
useUpdateWorkspaceName,
useWorkspacesQuery,
type Workspace,
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useWorkspaceManagement')
interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
interface UseWorkspaceManagementProps {
workspaceId: string
sessionUserId?: string
}
/**
* Manages workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
* Custom hook to manage workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
* Handles workspace validation and URL synchronization.
*
* @param props.workspaceId - The current workspace ID from the URL
* @param props.sessionUserId - The current user's session ID
* @returns Workspace state and operations
* @param props - Configuration object containing workspaceId and sessionUserId
* @returns Workspace management state and operations
*/
export function useWorkspaceManagement({
workspaceId,
@@ -35,68 +33,140 @@ export function useWorkspaceManagement({
}: UseWorkspaceManagementProps) {
const router = useRouter()
const pathname = usePathname()
const queryClient = useQueryClient()
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
const {
data: workspaces = [],
isLoading: isWorkspacesLoading,
refetch: refetchWorkspaces,
} = useWorkspacesQuery(Boolean(sessionUserId))
const leaveWorkspaceMutation = useLeaveWorkspace()
const createWorkspaceMutation = useCreateWorkspace()
const deleteWorkspaceMutation = useDeleteWorkspace()
const updateWorkspaceNameMutation = useUpdateWorkspaceName()
// Workspace management state
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
// Refs to avoid dependency issues
const workspaceIdRef = useRef<string>(workspaceId)
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
const pathnameRef = useRef<string | null>(pathname || null)
const hasValidatedRef = useRef<boolean>(false)
const activeWorkspaceRef = useRef<Workspace | null>(null)
const isInitializedRef = useRef<boolean>(false)
// Update refs when values change
workspaceIdRef.current = workspaceId
routerRef.current = router
pathnameRef.current = pathname || null
const activeWorkspace = useMemo(() => {
if (!workspaces.length) return null
return workspaces.find((w) => w.id === workspaceId) ?? null
}, [workspaces, workspaceId])
const activeWorkspaceRef = useRef<Workspace | null>(activeWorkspace)
activeWorkspaceRef.current = activeWorkspace
useEffect(() => {
if (isWorkspacesLoading || hasValidatedRef.current || !workspaces.length) {
return
}
const currentWorkspaceId = workspaceIdRef.current
const matchingWorkspace = workspaces.find((w) => w.id === currentWorkspaceId)
if (!matchingWorkspace) {
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
const fallbackWorkspace = workspaces[0]
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
routerRef.current?.push(`/workspace/${fallbackWorkspace.id}/w`)
}
hasValidatedRef.current = true
}, [workspaces, isWorkspacesLoading])
/**
* Refresh workspace list without validation logic - used for non-current workspace operations
*/
const refreshWorkspaceList = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
}, [queryClient])
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Only update activeWorkspace if it still exists in the fetched workspaces
// Use functional update to avoid dependency on activeWorkspace
setActiveWorkspace((currentActive) => {
if (!currentActive) {
return currentActive
}
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentActive.id
)
if (matchingWorkspace) {
return matchingWorkspace
}
// Active workspace was deleted, clear it
logger.warn(`Active workspace ${currentActive.id} no longer exists`)
return null
})
}
} catch (err) {
logger.error('Error refreshing workspace list:', err)
} finally {
setIsWorkspacesLoading(false)
}
}, [])
const fetchWorkspaces = useCallback(async () => {
hasValidatedRef.current = false
await refetchWorkspaces()
}, [refetchWorkspaces])
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Handle active workspace selection with URL validation using refs
const currentWorkspaceId = workspaceIdRef.current
const currentRouter = routerRef.current
if (currentWorkspaceId) {
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentWorkspaceId
)
if (matchingWorkspace) {
setActiveWorkspace(matchingWorkspace)
} else {
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
// Fallback to first workspace if current not found
if (fetchedWorkspaces.length > 0) {
const fallbackWorkspace = fetchedWorkspaces[0]
setActiveWorkspace(fallbackWorkspace)
// Update URL to match the fallback workspace
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
currentRouter?.push(`/workspace/${fallbackWorkspace.id}/w`)
} else {
logger.error('No workspaces available for user')
}
}
}
}
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsWorkspacesLoading(false)
}
}, [])
/**
* Update workspace name both in API and local state
*/
const updateWorkspaceName = useCallback(
async (workspaceId: string, newName: string): Promise<boolean> => {
try {
await updateWorkspaceNameMutation.mutateAsync({ workspaceId, name: newName })
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workspace name')
}
// Update local state immediately after successful API call
// Only update activeWorkspace if it's the one being renamed
setActiveWorkspace((prev) =>
prev && prev.id === workspaceId ? { ...prev, name: newName.trim() } : prev
)
setWorkspaces((prev) =>
prev.map((workspace) =>
workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace
)
)
logger.info('Successfully updated workspace name to:', newName.trim())
return true
} catch (error) {
@@ -104,18 +174,21 @@ export function useWorkspaceManagement({
return false
}
},
[updateWorkspaceNameMutation]
[]
)
const switchWorkspace = useCallback(
async (workspace: Workspace) => {
// If already on this workspace, return
if (activeWorkspaceRef.current?.id === workspace.id) {
return
}
try {
// Switch workspace and update URL
await switchToWorkspace(workspace.id)
const currentPath = pathnameRef.current || ''
// Preserve templates route if user is on templates or template detail
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
if (templateDetailMatch) {
const templateId = templateDetailMatch[1]
@@ -133,122 +206,208 @@ export function useWorkspaceManagement({
[switchToWorkspace]
)
/**
* Handle create workspace
*/
const handleCreateWorkspace = useCallback(async () => {
if (createWorkspaceMutation.isPending) {
if (isCreatingWorkspace) {
logger.info('Workspace creation already in progress, ignoring request')
return
}
try {
setIsCreatingWorkspace(true)
logger.info('Creating new workspace')
// Generate workspace name using utility function
const workspaceName = await generateWorkspaceName()
logger.info(`Generated workspace name: ${workspaceName}`)
const newWorkspace = await createWorkspaceMutation.mutateAsync({ name: workspaceName })
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: workspaceName,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create workspace')
}
const data = await response.json()
const newWorkspace = data.workspace
logger.info('Created new workspace:', newWorkspace)
// Refresh workspace list (no URL validation needed for creation)
await refreshWorkspaceList()
// Switch to the new workspace
await switchWorkspace(newWorkspace)
} catch (error) {
logger.error('Error creating workspace:', error)
} finally {
setIsCreatingWorkspace(false)
}
}, [createWorkspaceMutation, switchWorkspace])
}, [refreshWorkspaceList, switchWorkspace, isCreatingWorkspace])
/**
* Confirm delete workspace
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace, templateAction?: 'keep' | 'delete') => {
setIsDeleting(true)
try {
logger.info('Deleting workspace:', workspaceToDelete.id)
const deleteTemplates = templateAction === 'delete'
await deleteWorkspaceMutation.mutateAsync({
workspaceId: workspaceToDelete.id,
deleteTemplates,
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ deleteTemplates }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to delete workspace')
}
logger.info('Workspace deleted successfully:', workspaceToDelete.id)
// Check if we're deleting the current workspace (either active or in URL)
const isDeletingCurrentWorkspace =
workspaceIdRef.current === workspaceToDelete.id ||
activeWorkspaceRef.current?.id === workspaceToDelete.id
if (isDeletingCurrentWorkspace) {
// For current workspace deletion, use full fetchWorkspaces with URL validation
logger.info(
'Deleting current workspace - using full workspace refresh with URL validation'
)
hasValidatedRef.current = false
const { data: updatedWorkspaces } = await refetchWorkspaces()
await fetchWorkspaces()
const remainingWorkspaces = (updatedWorkspaces || []).filter(
(w) => w.id !== workspaceToDelete.id
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
// If we deleted the active workspace, switch to the first available workspace
if (activeWorkspaceRef.current?.id === workspaceToDelete.id) {
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToDelete.id)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
}
} else {
// For non-current workspace deletion, just refresh the list without URL validation
logger.info('Deleting non-current workspace - using simple list refresh')
await refreshWorkspaceList()
}
} catch (error) {
logger.error('Error deleting workspace:', error)
} finally {
setIsDeleting(false)
}
},
[deleteWorkspaceMutation, refetchWorkspaces, switchWorkspace]
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace]
)
/**
* Handle leave workspace
*/
const handleLeaveWorkspace = useCallback(
async (workspaceToLeave: Workspace) => {
if (!sessionUserId) {
logger.error('Cannot leave workspace: no session user ID')
return
}
logger.info('Leaving workspace:', workspaceToLeave.id)
setIsLeaving(true)
try {
await leaveWorkspaceMutation.mutateAsync({
userId: sessionUserId,
workspaceId: workspaceToLeave.id,
logger.info('Leaving workspace:', workspaceToLeave.id)
// Use the existing member removal API with current user's ID
const response = await fetch(`/api/workspaces/members/${sessionUserId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: workspaceToLeave.id,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to leave workspace')
}
logger.info('Left workspace successfully:', workspaceToLeave.id)
// Check if we're leaving the current workspace (either active or in URL)
const isLeavingCurrentWorkspace =
workspaceIdRef.current === workspaceToLeave.id ||
activeWorkspaceRef.current?.id === workspaceToLeave.id
if (isLeavingCurrentWorkspace) {
// For current workspace leaving, use full fetchWorkspaces with URL validation
logger.info(
'Leaving current workspace - using full workspace refresh with URL validation'
)
hasValidatedRef.current = false
const { data: updatedWorkspaces } = await refetchWorkspaces()
await fetchWorkspaces()
const remainingWorkspaces = (updatedWorkspaces || []).filter(
(w) => w.id !== workspaceToLeave.id
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
// If we left the active workspace, switch to the first available workspace
if (activeWorkspaceRef.current?.id === workspaceToLeave.id) {
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToLeave.id)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
}
} else {
// For non-current workspace leaving, just refresh the list without URL validation
logger.info('Leaving non-current workspace - using simple list refresh')
await refreshWorkspaceList()
}
} catch (error) {
logger.error('Error leaving workspace:', error)
throw error
} finally {
setIsLeaving(false)
}
},
[refetchWorkspaces, switchWorkspace, sessionUserId, leaveWorkspaceMutation]
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace, sessionUserId]
)
const isWorkspaceValid = useCallback(
(targetWorkspaceId: string) => {
return workspaces.some((w) => w.id === targetWorkspaceId)
},
[workspaces]
)
/**
* Validate workspace exists before making API calls
*/
const isWorkspaceValid = useCallback(async (workspaceId: string) => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`)
return response.ok
} catch {
return false
}
}, [])
/**
* Initialize workspace data on mount (uses full validation with URL handling)
* fetchWorkspaces is stable (empty deps array), so it's safe to call without including it
*/
useEffect(() => {
if (sessionUserId && !isInitializedRef.current) {
isInitializedRef.current = true
fetchWorkspaces()
}
}, [sessionUserId, fetchWorkspaces])
return {
// State
workspaces,
activeWorkspace,
isWorkspacesLoading,
isCreatingWorkspace: createWorkspaceMutation.isPending,
isDeleting: deleteWorkspaceMutation.isPending,
isLeaving: leaveWorkspaceMutation.isPending,
isCreatingWorkspace,
isDeleting,
isLeaving,
// Operations
fetchWorkspaces,
refreshWorkspaceList,
updateWorkspaceName,
@@ -259,5 +418,3 @@ export function useWorkspaceManagement({
isWorkspaceValid,
}
}
export type { Workspace }

View File

@@ -28,7 +28,7 @@ const checkboxVariants = cva(
'border-[var(--border-1)] bg-transparent',
'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--brand-tertiary-2)] data-[state=checked]:bg-[var(--brand-tertiary-2)]',
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
].join(' '),
{
variants: {

View File

@@ -20,7 +20,7 @@ import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
const comboboxVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
{
variants: {
variant: {
@@ -460,7 +460,7 @@ const Combobox = memo(
<Input
ref={inputRef}
className={cn(
'w-full pr-[40px] font-medium transition-colors',
'w-full pr-[40px] font-medium transition-colors hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
SelectedIcon && !overlayContent && 'pl-[28px]',
className

View File

@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
* Matches the combobox and input styling patterns.
*/
const datePickerVariants = cva(
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
{
variants: {
variant: {
@@ -82,12 +82,10 @@ interface DatePickerBaseProps
interface DatePickerSingleProps extends DatePickerBaseProps {
/** Selection mode */
mode?: 'single'
/** Current selected date value (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss string, or Date) */
/** Current selected date value (YYYY-MM-DD string or Date) */
value?: string | Date
/** Callback when date changes, returns YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format */
/** Callback when date changes, returns YYYY-MM-DD format */
onChange?: (value: string) => void
/** When true, shows time picker after date selection and outputs ISO 8601 format */
showTime?: boolean
/** Not used in single mode */
startDate?: never
/** Not used in single mode */
@@ -179,91 +177,14 @@ const MONTHS_SHORT = [
/**
* Formats a date for display in the trigger button.
* If time is provided, formats as "Jan 30, 2026 at 2:30 PM"
*/
function formatDateForDisplay(date: Date | null, time?: string | null): string {
function formatDateForDisplay(date: Date | null): string {
if (!date) return ''
const dateStr = date.toLocaleDateString('en-US', {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
if (time) {
return `${dateStr} at ${formatDisplayTime(time)}`
}
return dateStr
}
/**
* Converts a 24h time string to 12h display format with AM/PM.
*/
function formatDisplayTime(time: string): string {
if (!time) return ''
const [hours, minutes] = time.split(':')
const hour = Number.parseInt(hours, 10)
const ampm = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour}:${minutes} ${ampm}`
}
/**
* Converts 12h time components to 24h format string.
*/
function formatStorageTime(hour: number, minute: number, ampm: 'AM' | 'PM'): string {
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
/**
* Parses a 24h time string into 12h components.
* Returns default 12:00 PM if no time provided (for UI display only).
*/
function parseTimeComponents(time: string | null): {
hour: string
minute: string
ampm: 'AM' | 'PM'
} {
if (!time) return { hour: '12', minute: '00', ampm: 'PM' }
const [hours, minutes] = time.split(':')
const hour24 = Number.parseInt(hours, 10)
const isAM = hour24 < 12
return {
hour: (hour24 % 12 || 12).toString(),
minute: minutes || '00',
ampm: isAM ? 'AM' : 'PM',
}
}
/**
* Checks if a value contains time information.
*/
function valueHasTime(value: string | Date | undefined): boolean {
if (!value) return false
if (value instanceof Date) {
// Check if time is not midnight (default)
return value.getHours() !== 0 || value.getMinutes() !== 0
}
// Check for ISO datetime format: YYYY-MM-DDTHH:mm
return /T\d{2}:\d{2}/.test(value)
}
/**
* Extracts time from a datetime string or Date object.
* Returns HH:mm format or null if no time present.
*/
function extractTimeFromValue(value: string | Date | undefined): string | null {
if (!value) return null
if (value instanceof Date) {
// Only return time if it's not midnight (which could be default)
if (value.getHours() === 0 && value.getMinutes() === 0) return null
return `${value.getHours().toString().padStart(2, '0')}:${value.getMinutes().toString().padStart(2, '0')}`
}
// Check for ISO datetime format: YYYY-MM-DDTHH:mm:ss
const match = value.match(/T(\d{2}):(\d{2})/)
if (match) {
return `${match[1]}:${match[2]}`
}
return null
}
/**
@@ -307,16 +228,12 @@ function isSameDay(date1: Date, date2: Date): boolean {
}
/**
* Formats a date as YYYY-MM-DD string, optionally with time as YYYY-MM-DDTHH:mm:ss.
* Formats a date as YYYY-MM-DD string.
*/
function formatDateAsString(year: number, month: number, day: number, time?: string): string {
function formatDateAsString(year: number, month: number, day: number): string {
const m = (month + 1).toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
const dateStr = `${year}-${m}-${d}`
if (time) {
return `${dateStr}T${time}:00`
}
return dateStr
return `${year}-${m}-${d}`
}
/**
@@ -581,7 +498,6 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
const {
value: _value,
onChange: _onChange,
showTime: _showTime,
startDate: _startDate,
endDate: _endDate,
onRangeChange: _onRangeChange,
@@ -591,7 +507,6 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
} = rest as any
const isRangeMode = props.mode === 'range'
const showTime = !isRangeMode && (props as DatePickerSingleProps).showTime === true
const isControlled = controlledOpen !== undefined
const [internalOpen, setInternalOpen] = React.useState(false)
@@ -609,37 +524,6 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
const selectedDate = !isRangeMode ? parseDate(props.value) : null
// Time state for showTime mode
// Track whether the incoming value has time
const valueTimeInfo = React.useMemo(() => {
if (!showTime) return { hasTime: false, time: null }
const time = extractTimeFromValue(props.value)
return { hasTime: time !== null, time }
}, [showTime, props.value])
const parsedTime = React.useMemo(
() => parseTimeComponents(valueTimeInfo.time),
[valueTimeInfo.time]
)
const [hour, setHour] = React.useState(parsedTime.hour)
const [minute, setMinute] = React.useState(parsedTime.minute)
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsedTime.ampm)
// Track whether user has explicitly set time (either from value or interaction)
const [timeWasSet, setTimeWasSet] = React.useState(valueTimeInfo.hasTime)
const hourInputRef = React.useRef<HTMLInputElement>(null)
// Sync time state when value changes
React.useEffect(() => {
if (showTime) {
const time = extractTimeFromValue(props.value)
const newParsed = parseTimeComponents(time)
setHour(newParsed.hour)
setMinute(newParsed.minute)
setAmpm(newParsed.ampm)
setTimeWasSet(time !== null)
}
}, [showTime, props.value])
const initialStart = isRangeMode ? parseDate(props.startDate) : null
const initialEnd = isRangeMode ? parseDate(props.endDate) : null
const [rangeStart, setRangeStart] = React.useState<Date | null>(initialStart)
@@ -682,186 +566,17 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
}
}, [isRangeMode, selectedDate])
/**
* Gets the current time string in 24h format.
*/
const getCurrentTimeString = React.useCallback(() => {
const h = Number.parseInt(hour) || 12
const m = Number.parseInt(minute) || 0
return formatStorageTime(h, m, ampm)
}, [hour, minute, ampm])
/**
* Handles selection of a specific day in single mode.
*/
const handleSelectDateSingle = React.useCallback(
(day: number) => {
if (!isRangeMode && props.onChange) {
if (showTime && timeWasSet) {
// Only include time if it was explicitly set
props.onChange(formatDateAsString(viewYear, viewMonth, day, getCurrentTimeString()))
} else {
props.onChange(formatDateAsString(viewYear, viewMonth, day))
if (!showTime) {
setOpen(false)
}
}
}
},
[
isRangeMode,
viewYear,
viewMonth,
props.onChange,
setOpen,
showTime,
getCurrentTimeString,
timeWasSet,
]
)
/**
* Handles hour input change.
*/
const handleHourChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setHour(val)
}, [])
/**
* Handles hour input blur - validates and updates value.
*/
const handleHourBlur = React.useCallback(() => {
const numVal = Number.parseInt(hour) || 12
const clamped = Math.min(12, Math.max(1, numVal))
setHour(clamped.toString())
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(clamped, Number.parseInt(minute) || 0, ampm)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
}, [hour, minute, ampm, selectedDate, props.onChange, showTime])
/**
* Handles minute input change.
*/
const handleMinuteChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setMinute(val)
}, [])
/**
* Handles minute input blur - validates and updates value.
*/
const handleMinuteBlur = React.useCallback(() => {
const numVal = Number.parseInt(minute) || 0
const clamped = Math.min(59, Math.max(0, numVal))
setMinute(clamped.toString().padStart(2, '0'))
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(Number.parseInt(hour) || 12, clamped, ampm)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
}, [minute, hour, ampm, selectedDate, props.onChange, showTime])
/**
* Handles AM/PM toggle.
*/
const handleAmpmChange = React.useCallback(
(newAmpm: 'AM' | 'PM') => {
setAmpm(newAmpm)
setTimeWasSet(true)
if (selectedDate && props.onChange && showTime) {
const timeStr = formatStorageTime(
Number.parseInt(hour) || 12,
Number.parseInt(minute) || 0,
newAmpm
)
props.onChange(
formatDateAsString(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate(),
timeStr
)
)
}
},
[hour, minute, selectedDate, props.onChange, showTime]
)
/**
* Handles keyboard navigation in hour input (Enter, ArrowUp, ArrowDown).
*/
const handleHourKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
props.onChange(formatDateAsString(viewYear, viewMonth, day))
setOpen(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setHour((prev) => {
const num = Number.parseInt(prev, 10) || 12
const next = num >= 12 ? 1 : num + 1
return next.toString()
})
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setHour((prev) => {
const num = Number.parseInt(prev, 10) || 12
const next = num <= 1 ? 12 : num - 1
return next.toString()
})
}
},
[setOpen, timeWasSet]
)
/**
* Handles keyboard navigation in minute input (Enter, ArrowUp, ArrowDown).
*/
const handleMinuteKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
setOpen(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setMinute((prev) => {
const num = Number.parseInt(prev, 10) || 0
const next = num >= 59 ? 0 : num + 1
return next.toString().padStart(2, '0')
})
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (!timeWasSet) setTimeWasSet(true)
setMinute((prev) => {
const num = Number.parseInt(prev, 10) || 0
const next = num <= 0 ? 59 : num - 1
return next.toString().padStart(2, '0')
})
}
},
[setOpen, timeWasSet]
[isRangeMode, viewYear, viewMonth, props.onChange, setOpen]
)
/**
@@ -925,31 +640,16 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
/**
* Selects today's date (single mode only).
* Preserves existing time if set, otherwise outputs date only.
*/
const handleSelectToday = React.useCallback(() => {
if (!isRangeMode && props.onChange) {
const now = new Date()
setViewMonth(now.getMonth())
setViewYear(now.getFullYear())
if (showTime && timeWasSet) {
// Only include time if it was explicitly set
props.onChange(
formatDateAsString(
now.getFullYear(),
now.getMonth(),
now.getDate(),
getCurrentTimeString()
)
)
} else {
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
if (!showTime) {
setOpen(false)
}
}
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
setOpen(false)
}
}, [isRangeMode, props.onChange, setOpen, showTime, getCurrentTimeString, timeWasSet])
}, [isRangeMode, props.onChange, setOpen])
/**
* Applies the selected range (range mode only).
@@ -1010,11 +710,9 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
}
}, [disabled, open, setOpen])
// Only show time in display if it was explicitly set
const displayTime = showTime && timeWasSet ? getCurrentTimeString() : null
const displayValue = isRangeMode
? formatDateRangeForDisplay(initialStart, initialEnd)
: formatDateForDisplay(selectedDate, displayTime)
: formatDateForDisplay(selectedDate)
const calendarContent = isRangeMode ? (
<>
@@ -1085,81 +783,12 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
onNextMonth={goToNextMonth}
/>
{/* Time Picker (when showTime is enabled) */}
{showTime && (
<div className='flex items-center justify-center gap-[6px] border-[var(--border-1)] border-t px-[12px] py-[10px]'>
<span className='font-medium text-[12px] text-[var(--text-muted)]'>Time:</span>
<input
ref={hourInputRef}
className={cn(
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
)}
value={hour}
onChange={(e) => {
handleHourChange(e)
if (!timeWasSet) setTimeWasSet(true)
}}
onBlur={handleHourBlur}
onFocus={(e) => e.target.select()}
onKeyDown={handleHourKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<span className='font-medium text-[13px] text-[var(--text-muted)]'>:</span>
<input
className={cn(
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
)}
value={minute}
onChange={(e) => {
handleMinuteChange(e)
if (!timeWasSet) setTimeWasSet(true)
}}
onBlur={handleMinuteBlur}
onFocus={(e) => e.target.select()}
onKeyDown={handleMinuteKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<div
className={cn(
'ml-[2px] flex overflow-hidden rounded-[4px] border border-[var(--border-1)]',
!timeWasSet && 'opacity-50'
)}
>
{(['AM', 'PM'] as const).map((period) => (
<button
key={period}
type='button'
onClick={() => handleAmpmChange(period)}
className={cn(
'px-[8px] py-[5px] font-medium font-sans text-[12px] transition-colors',
timeWasSet && ampm === period
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
)}
>
{period}
</button>
))}
</div>
</div>
)}
{/* Today Button (only shown when time picker is not enabled) */}
{!showTime && (
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
)}
{/* Today Button */}
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
</>
)

View File

@@ -322,8 +322,7 @@ describe('ConditionBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
// collectBlockData is now called with the current node ID for parallel branch context
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext, mockBlock.id)
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext)
})
it('should handle function_execute tool failure', async () => {
@@ -621,248 +620,4 @@ describe('ConditionBlockHandler', () => {
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
})
describe('Parallel branch handling', () => {
it('should resolve connections and block data correctly when inside a parallel branch', async () => {
// Simulate a condition block inside a parallel branch
// Virtual block ID uses subscript notation: blockId₍branchIndex₎
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍0₎', // Virtual ID for branch 0
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
// Source block also has a virtual ID in the same branch
const sourceBlockVirtualId = 'agent-block-1₍0₎'
// Set up workflow with connections using BASE block IDs (as they are in the workflow definition)
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
// Connections use base IDs, not virtual IDs
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
// Block states use virtual IDs (as outputs are stored per-branch)
const parallelBlockStates = new Map<string, BlockState>([
[
sourceBlockVirtualId,
{ output: { response: 'hello from branch 0', success: true }, executed: true },
],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.response === "hello from branch 0"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// The condition should evaluate to true because:
// 1. Connection lookup uses base ID 'cond-block-1' (extracted from 'cond-block-1₍0₎')
// 2. Source block output is found at virtual ID 'agent-block-1₍0₎' (same branch)
// 3. The evaluation context contains { response: 'hello from branch 0' }
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: 'target-block-1',
blockType: 'api',
blockTitle: 'Target',
})
})
it('should find correct source block output in parallel branch context', async () => {
// Test that when multiple branches exist, the correct branch output is used
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍1₎', // Virtual ID for branch 1
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
// Multiple branches have executed - each has different output
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 10 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 25 }, executed: true }], // Branch 1 has value 25
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
// Condition checks if value > 20 - should be true for branch 1 (value=25)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// Should evaluate using branch 1's data (value=25), not branch 0 (value=10) or branch 2 (value=5)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
})
it('should fall back to else when condition is false in parallel branch', async () => {
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍2₎', // Virtual ID for branch 2
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-true',
metadata: { id: 'api', name: 'True Path' },
position: { x: 200, y: 0 },
config: {},
},
{
id: 'target-false',
metadata: { id: 'api', name: 'False Path' },
position: { x: 200, y: 100 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-true', sourceHandle: 'condition-cond1' },
{ source: 'cond-block-1', target: 'target-false', sourceHandle: 'condition-else1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 100 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 50 }, executed: true }],
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }], // Branch 2 has value 5
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
// Condition checks if value > 20 - should be false for branch 2 (value=5)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// Should fall back to else path because branch 2's value (5) is not > 20
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath.blockId).toBe('target-false')
})
})
})

View File

@@ -3,12 +3,6 @@ import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
buildBranchNodeId,
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -24,8 +18,7 @@ const CONDITION_TIMEOUT_MS = 5000
export async function evaluateConditionExpression(
ctx: ExecutionContext,
conditionExpression: string,
providedEvalContext?: Record<string, any>,
currentNodeId?: string
providedEvalContext?: Record<string, any>
): Promise<boolean> {
const evalContext = providedEvalContext || {}
@@ -33,7 +26,7 @@ export async function evaluateConditionExpression(
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
@@ -90,19 +83,7 @@ export class ConditionBlockHandler implements BlockHandler {
): Promise<BlockOutput> {
const conditions = this.parseConditions(inputs.conditions)
const baseBlockId = extractBaseBlockId(block.id)
const branchIndex = isBranchNodeId(block.id) ? extractBranchIndex(block.id) : null
const sourceConnection = ctx.workflow?.connections.find((conn) => conn.target === baseBlockId)
let sourceBlockId = sourceConnection?.source
if (sourceBlockId && branchIndex !== null) {
const virtualSourceId = buildBranchNodeId(sourceBlockId, branchIndex)
if (ctx.blockStates.has(virtualSourceId)) {
sourceBlockId = virtualSourceId
}
}
const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
const rawSourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
@@ -110,16 +91,13 @@ export class ConditionBlockHandler implements BlockHandler {
// thinking this block is pausing (it was already resumed by the HITL block)
const sourceOutput = this.filterPauseMetadata(rawSourceOutput)
const outgoingConnections = ctx.workflow?.connections.filter(
(conn) => conn.source === baseBlockId
)
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
const { selectedConnection, selectedCondition } = await this.evaluateConditions(
conditions,
outgoingConnections || [],
evalContext,
ctx,
block.id
ctx
)
if (!selectedConnection || !selectedCondition) {
@@ -192,8 +170,7 @@ export class ConditionBlockHandler implements BlockHandler {
conditions: Array<{ id: string; title: string; value: string }>,
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
evalContext: Record<string, any>,
ctx: ExecutionContext,
currentNodeId?: string
ctx: ExecutionContext
): Promise<{
selectedConnection: { target: string; sourceHandle?: string } | null
selectedCondition: { id: string; title: string; value: string } | null
@@ -212,8 +189,7 @@ export class ConditionBlockHandler implements BlockHandler {
const conditionMet = await evaluateConditionExpression(
ctx,
conditionValueString,
evalContext,
currentNodeId
evalContext
)
if (conditionMet) {

View File

@@ -2,11 +2,6 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { OutputSchema } from '@/executor/utils/block-reference'
import {
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
@@ -91,30 +86,14 @@ export function getBlockSchema(
return undefined
}
export function collectBlockData(
ctx: ExecutionContext,
currentNodeId?: string
): BlockDataCollection {
export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
const blockData: Record<string, unknown> = {}
const blockNameMapping: Record<string, string> = {}
const blockOutputSchemas: Record<string, OutputSchema> = {}
const branchIndex =
currentNodeId && isBranchNodeId(currentNodeId) ? extractBranchIndex(currentNodeId) : null
for (const [id, state] of ctx.blockStates.entries()) {
if (state.output !== undefined) {
blockData[id] = state.output
if (branchIndex !== null && isBranchNodeId(id)) {
const stateBranchIndex = extractBranchIndex(id)
if (stateBranchIndex === branchIndex) {
const baseId = extractBaseBlockId(id)
if (blockData[baseId] === undefined) {
blockData[baseId] = state.output
}
}
}
}
}

View File

@@ -1,309 +0,0 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { workspaceKeys } from './workspace'
/**
* Query key factory for invitation-related queries.
* Provides hierarchical cache keys for workspace invitations.
*/
export const invitationKeys = {
all: ['invitations'] as const,
lists: () => [...invitationKeys.all, 'list'] as const,
list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const,
}
/** Raw invitation data from the API. */
export interface PendingInvitation {
id: string
workspaceId: string
email: string
permissions: 'admin' | 'write' | 'read'
status: string
createdAt: string
}
/** Normalized invitation for display in the UI. */
export interface WorkspaceInvitation {
email: string
permissionType: 'admin' | 'write' | 'read'
isPendingInvitation: boolean
invitationId?: string
}
async function fetchPendingInvitations(workspaceId: string): Promise<WorkspaceInvitation[]> {
const response = await fetch('/api/workspaces/invitations')
if (!response.ok) {
throw new Error('Failed to fetch pending invitations')
}
const data = await response.json()
return (
data.invitations
?.filter(
(inv: PendingInvitation) => inv.status === 'pending' && inv.workspaceId === workspaceId
)
.map((inv: PendingInvitation) => ({
email: inv.email,
permissionType: inv.permissions,
isPendingInvitation: true,
invitationId: inv.id,
})) || []
)
}
/**
* Fetches pending invitations for a workspace.
* @param workspaceId - The workspace ID to fetch invitations for
*/
export function usePendingInvitations(workspaceId: string | undefined) {
return useQuery({
queryKey: invitationKeys.list(workspaceId ?? ''),
queryFn: () => fetchPendingInvitations(workspaceId as string),
enabled: Boolean(workspaceId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface BatchSendInvitationsParams {
workspaceId: string
invitations: Array<{ email: string; permission: 'admin' | 'write' | 'read' }>
}
interface BatchInvitationResult {
successful: string[]
failed: Array<{ email: string; error: string }>
}
/**
* Sends multiple workspace invitations in parallel.
* Returns results for each invitation indicating success or failure.
*/
export function useBatchSendWorkspaceInvitations() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
invitations,
}: BatchSendInvitationsParams): Promise<BatchInvitationResult> => {
const results = await Promise.allSettled(
invitations.map(async ({ email, permission }) => {
const response = await fetch('/api/workspaces/invitations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
email,
role: 'member',
permission,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to send invitation')
}
return { email, data: await response.json() }
})
)
const successful: string[] = []
const failed: Array<{ email: string; error: string }> = []
results.forEach((result, index) => {
const email = invitations[index].email
if (result.status === 'fulfilled') {
successful.push(email)
} else {
failed.push({ email, error: result.reason?.message || 'Unknown error' })
}
})
return { successful, failed }
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface CancelInvitationParams {
invitationId: string
workspaceId: string
}
/**
* Cancels a pending workspace invitation.
* Invalidates the invitation list cache on success.
*/
export function useCancelWorkspaceInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ invitationId }: CancelInvitationParams) => {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to cancel invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface ResendInvitationParams {
invitationId: string
workspaceId: string
}
/**
* Resends a pending workspace invitation email.
* Invalidates the invitation list cache on success.
*/
export function useResendWorkspaceInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ invitationId }: ResendInvitationParams) => {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to resend invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface RemoveMemberParams {
userId: string
workspaceId: string
}
/**
* Removes a member from a workspace.
* Invalidates the workspace permissions cache on success.
*/
export function useRemoveWorkspaceMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, workspaceId }: RemoveMemberParams) => {
const response = await fetch(`/api/workspaces/members/${userId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to remove member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
},
})
}
interface LeaveWorkspaceParams {
userId: string
workspaceId: string
}
/**
* Allows the current user to leave a workspace.
* Invalidates both permissions and workspace list caches on success.
*/
export function useLeaveWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, workspaceId }: LeaveWorkspaceParams) => {
const response = await fetch(`/api/workspaces/members/${userId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to leave workspace')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workspaceKeys.all,
})
},
})
}
interface UpdatePermissionsParams {
workspaceId: string
updates: Array<{ userId: string; permissions: 'admin' | 'write' | 'read' }>
}
/**
* Updates permissions for one or more workspace members.
* Invalidates the workspace permissions cache on success.
*/
export function useUpdateWorkspacePermissions() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, updates }: UpdatePermissionsParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update permissions')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
},
})
}

View File

@@ -17,8 +17,6 @@ export const knowledgeKeys = {
[...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const,
tagDefinitions: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
tagUsage: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const,
documents: (knowledgeBaseId: string, paramsKey: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const,
document: (knowledgeBaseId: string, documentId: string) =>
@@ -912,38 +910,6 @@ export function useTagDefinitionsQuery(knowledgeBaseId?: string | null) {
})
}
export interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
export async function fetchTagUsage(knowledgeBaseId: string): Promise<TagUsageData[]> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error(`Failed to fetch tag usage: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch tag usage')
}
return Array.isArray(result.data) ? result.data : []
}
export function useTagUsageQuery(knowledgeBaseId?: string | null) {
return useQuery({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId ?? ''),
queryFn: () => fetchTagUsage(knowledgeBaseId as string),
enabled: Boolean(knowledgeBaseId),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
export interface CreateTagDefinitionParams {
knowledgeBaseId: string
displayName: string
@@ -1002,9 +968,6 @@ export function useCreateTagDefinition() {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
})
},
})
}
@@ -1043,9 +1006,6 @@ export function useDeleteTagDefinition() {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
})
},
})
}

View File

@@ -15,8 +15,6 @@ export const logKeys = {
lists: () => [...logKeys.all, 'list'] as const,
list: (workspaceId: string | undefined, filters: Omit<LogFilters, 'page'>) =>
[...logKeys.lists(), workspaceId ?? '', filters] as const,
recent: (workspaceId: string | undefined, limit: number) =>
[...logKeys.all, 'recent', workspaceId ?? '', limit] as const,
details: () => [...logKeys.all, 'detail'] as const,
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
stats: (workspaceId: string | undefined, filters: object) =>
@@ -250,57 +248,3 @@ export function useExecutionSnapshot(executionId: string | undefined) {
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
})
}
/**
* Simple recent logs data for lightweight use cases (e.g., mention suggestions)
*/
export interface RecentLog {
id: string
executionId?: string
level: string
trigger: string | null
createdAt: string
workflow?: {
name?: string
title?: string
}
workflowName?: string
}
async function fetchRecentLogs(workspaceId: string, limit: number): Promise<RecentLog[]> {
const params = new URLSearchParams()
params.set('workspaceId', workspaceId)
params.set('limit', limit.toString())
params.set('details', 'full')
const response = await fetch(`/api/logs?${params.toString()}`)
if (!response.ok) {
throw new Error('Failed to fetch recent logs')
}
const data = await response.json()
return Array.isArray(data?.data) ? data.data : []
}
interface UseRecentLogsOptions {
enabled?: boolean
}
/**
* Hook for fetching recent logs with minimal filtering.
* Useful for lightweight use cases like mention suggestions.
*/
export function useRecentLogs(
workspaceId: string | undefined,
limit = 50,
options?: UseRecentLogsOptions
) {
return useQuery({
queryKey: logKeys.recent(workspaceId, limit),
queryFn: () => fetchRecentLogs(workspaceId as string, limit),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -6,32 +6,27 @@ import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
const logger = createLogger('OAuthConnectionsQuery')
/**
* Query key factory for OAuth connection queries.
* Provides hierarchical cache keys for connections and provider-specific accounts.
* Query key factories for OAuth connections
*/
export const oauthConnectionsKeys = {
all: ['oauthConnections'] as const,
connections: () => [...oauthConnectionsKeys.all, 'connections'] as const,
accounts: (provider: string) => [...oauthConnectionsKeys.all, 'accounts', provider] as const,
}
/** OAuth service with connection status and linked accounts. */
/**
* Service info type - extends OAuthServiceConfig with connection status and the service key
*/
export interface ServiceInfo extends OAuthServiceConfig {
/** The service key from OAUTH_PROVIDERS (e.g., 'gmail', 'google-drive') */
id: string
isConnected: boolean
lastConnected?: string
accounts?: { id: string; name: string }[]
}
/** OAuth connection data returned from the API. */
interface OAuthConnectionResponse {
provider: string
baseProvider?: string
accounts?: { id: string; name: string }[]
lastConnected?: string
scopes?: string[]
}
/**
* Define available services from standardized OAuth providers
*/
function defineServices(): ServiceInfo[] {
const servicesList: ServiceInfo[] = []
@@ -49,6 +44,9 @@ function defineServices(): ServiceInfo[] {
return servicesList
}
/**
* Fetch OAuth connections and merge with service definitions
*/
async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
try {
const serviceDefinitions = defineServices()
@@ -67,9 +65,7 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
const connections = data.connections || []
const updatedServices = serviceDefinitions.map((service) => {
const connection = connections.find(
(conn: OAuthConnectionResponse) => conn.provider === service.providerId
)
const connection = connections.find((conn: any) => conn.provider === service.providerId)
if (connection) {
return {
@@ -80,14 +76,13 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
}
}
const connectionWithScopes = connections.find((conn: OAuthConnectionResponse) => {
const connectionWithScopes = connections.find((conn: any) => {
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
return false
}
if (conn.scopes && service.scopes) {
const connScopes = conn.scopes
return service.scopes.every((scope) => connScopes.includes(scope))
return service.scopes.every((scope) => conn.scopes.includes(scope))
}
return false
@@ -113,28 +108,26 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
}
/**
* Fetches all OAuth service connections with their status.
* Returns service definitions merged with connection data.
* Hook to fetch OAuth connections
*/
export function useOAuthConnections() {
return useQuery({
queryKey: oauthConnectionsKeys.connections(),
queryFn: fetchOAuthConnections,
staleTime: 30 * 1000,
retry: false,
placeholderData: keepPreviousData,
staleTime: 30 * 1000, // 30 seconds - connections don't change often
retry: false, // Don't retry on 404
placeholderData: keepPreviousData, // Show cached data immediately
})
}
/**
* Connect OAuth service mutation
*/
interface ConnectServiceParams {
providerId: string
callbackURL: string
}
/**
* Initiates OAuth connection flow for a service.
* Redirects the user to the provider's authorization page.
*/
export function useConnectOAuthService() {
const queryClient = useQueryClient()
@@ -145,6 +138,7 @@ export function useConnectOAuthService() {
return { success: true }
}
// Shopify requires a custom OAuth flow with shop domain input
if (providerId === 'shopify') {
const returnUrl = encodeURIComponent(callbackURL)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
@@ -167,6 +161,9 @@ export function useConnectOAuthService() {
})
}
/**
* Disconnect OAuth service mutation
*/
interface DisconnectServiceParams {
provider: string
providerId: string
@@ -174,10 +171,6 @@ interface DisconnectServiceParams {
accountId: string
}
/**
* Disconnects an OAuth service account.
* Performs optimistic update and rolls back on failure.
*/
export function useDisconnectOAuthService() {
const queryClient = useQueryClient()
@@ -237,38 +230,3 @@ export function useDisconnectOAuthService() {
},
})
}
/** Connected OAuth account for a specific provider. */
export interface ConnectedAccount {
id: string
accountId: string
providerId: string
displayName?: string
}
async function fetchConnectedAccounts(provider: string): Promise<ConnectedAccount[]> {
const response = await fetch(`/api/auth/accounts?provider=${provider}`)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || `Failed to load ${provider} accounts`)
}
const data = await response.json()
return data.accounts || []
}
/**
* Fetches connected accounts for a specific OAuth provider.
* @param provider - The provider ID (e.g., 'slack', 'google')
* @param options - Query options including enabled flag
*/
export function useConnectedAccounts(provider: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: oauthConnectionsKeys.accounts(provider),
queryFn: () => fetchConnectedAccounts(provider),
enabled: options?.enabled ?? true,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -1,13 +1,10 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
/**
* Query key factory for workspace-related queries.
* Provides hierarchical cache keys for workspaces, settings, and permissions.
* Query key factories for workspace-related queries
*/
export const workspaceKeys = {
all: ['workspace'] as const,
lists: () => [...workspaceKeys.all, 'list'] as const,
list: () => [...workspaceKeys.lists(), 'user'] as const,
details: () => [...workspaceKeys.all, 'detail'] as const,
detail: (id: string) => [...workspaceKeys.details(), id] as const,
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
@@ -16,186 +13,9 @@ export const workspaceKeys = {
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
}
/** Represents a workspace in the user's workspace list. */
export interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
async function fetchWorkspaces(): Promise<Workspace[]> {
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
return data.workspaces || []
}
/**
* Fetches the current user's workspaces.
* @param enabled - Whether the query should execute (defaults to true)
* Fetch workspace settings
*/
export function useWorkspacesQuery(enabled = true) {
return useQuery({
queryKey: workspaceKeys.list(),
queryFn: fetchWorkspaces,
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface CreateWorkspaceParams {
name: string
}
/**
* Creates a new workspace.
* Automatically invalidates the workspace list cache on success.
*/
export function useCreateWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ name }: CreateWorkspaceParams) => {
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create workspace')
}
const data = await response.json()
return data.workspace as Workspace
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
},
})
}
interface DeleteWorkspaceParams {
workspaceId: string
deleteTemplates?: boolean
}
/**
* Deletes a workspace.
* Automatically invalidates the workspace list cache on success.
*/
export function useDeleteWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, deleteTemplates = false }: DeleteWorkspaceParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deleteTemplates }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete workspace')
}
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
},
})
}
interface UpdateWorkspaceNameParams {
workspaceId: string
name: string
}
/**
* Updates a workspace's name.
* Invalidates both the workspace list and the specific workspace detail cache.
*/
export function useUpdateWorkspaceName() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, name }: UpdateWorkspaceNameParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workspace name')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
},
})
}
/** Represents a user with permissions in a workspace. */
export interface WorkspaceUser {
userId: string
email: string
name: string | null
image: string | null
permissionType: 'admin' | 'write' | 'read'
}
/** Workspace permissions data containing all users and their access levels. */
export interface WorkspacePermissions {
users: WorkspaceUser[]
total: number
}
async function fetchWorkspacePermissions(workspaceId: string): Promise<WorkspacePermissions> {
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`)
if (!response.ok) {
if (response.status === 404) {
throw new Error('Workspace not found or access denied')
}
if (response.status === 401) {
throw new Error('Authentication required')
}
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
}
return response.json()
}
/**
* Fetches permissions for a specific workspace.
* @param workspaceId - The workspace ID to fetch permissions for
*/
export function useWorkspacePermissionsQuery(workspaceId: string | null | undefined) {
return useQuery({
queryKey: workspaceKeys.permissions(workspaceId ?? ''),
queryFn: () => fetchWorkspacePermissions(workspaceId as string),
enabled: Boolean(workspaceId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
async function fetchWorkspaceSettings(workspaceId: string) {
const [settingsResponse, permissionsResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}`),
@@ -218,8 +38,7 @@ async function fetchWorkspaceSettings(workspaceId: string) {
}
/**
* Fetches workspace settings including permissions.
* @param workspaceId - The workspace ID to fetch settings for
* Hook to fetch workspace settings
*/
export function useWorkspaceSettings(workspaceId: string) {
return useQuery({
@@ -231,16 +50,15 @@ export function useWorkspaceSettings(workspaceId: string) {
})
}
/**
* Update workspace settings mutation
*/
interface UpdateWorkspaceSettingsParams {
workspaceId: string
billedAccountUserId?: string
billingAccountUserEmail?: string
}
/**
* Updates workspace settings (e.g., billing configuration).
* Invalidates the workspace settings cache on success.
*/
export function useUpdateWorkspaceSettings() {
const queryClient = useQueryClient()
@@ -267,7 +85,9 @@ export function useUpdateWorkspaceSettings() {
})
}
/** Workspace with admin access metadata. */
/**
* Workspace type returned by admin workspaces query
*/
export interface AdminWorkspace {
id: string
name: string
@@ -276,6 +96,9 @@ export interface AdminWorkspace {
canInvite: boolean
}
/**
* Fetch workspaces where user has admin access
*/
async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWorkspace[]> {
if (!userId) {
return []
@@ -298,7 +121,7 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
}
const permissionData = await permissionResponse.json()
return { workspace, permissionData }
} catch (_error) {
} catch (error) {
return null
}
}
@@ -338,15 +161,14 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
}
/**
* Fetches workspaces where the user has admin access.
* @param userId - The user ID to check admin access for
* Hook to fetch workspaces where user has admin access
*/
export function useAdminWorkspaces(userId: string | undefined) {
return useQuery({
queryKey: workspaceKeys.adminList(userId),
queryFn: () => fetchAdminWorkspaces(userId),
enabled: Boolean(userId),
staleTime: 60 * 1000,
staleTime: 60 * 1000, // Cache for 60 seconds
placeholderData: keepPreviousData,
})
}

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react'
interface SlackAccount {
id: string
accountId: string
providerId: string
displayName?: string
}
interface UseSlackAccountsResult {
accounts: SlackAccount[]
isLoading: boolean
error: string | null
refetch: () => Promise<void>
}
/**
* Fetches and manages connected Slack accounts for the current user.
* @returns Object containing accounts array, loading state, error state, and refetch function
*/
export function useSlackAccounts(): UseSlackAccountsResult {
const [accounts, setAccounts] = useState<SlackAccount[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAccounts = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch('/api/auth/accounts?provider=slack')
if (response.ok) {
const data = await response.json()
setAccounts(data.accounts || [])
} else {
const data = await response.json().catch(() => ({}))
setError(data.error || 'Failed to load Slack accounts')
setAccounts([])
}
} catch {
setError('Failed to load Slack accounts')
setAccounts([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchAccounts()
}, [])
return { accounts, isLoading, error, refetch: fetchAccounts }
}

View File

@@ -1,9 +1,7 @@
import { useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useSession } from '@/lib/auth/auth-client'
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
export type PermissionType = 'admin' | 'write' | 'read'
import type { PermissionType, WorkspacePermissions } from '@/hooks/use-workspace-permissions'
const logger = createLogger('useUserPermissions')

View File

@@ -0,0 +1,107 @@
import { useCallback, useEffect, useState } from 'react'
import type { permissionTypeEnum } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { API_ENDPOINTS } from '@/stores/constants'
const logger = createLogger('useWorkspacePermissions')
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
export interface WorkspaceUser {
userId: string
email: string
name: string | null
image: string | null
permissionType: PermissionType
}
export interface WorkspacePermissions {
users: WorkspaceUser[]
total: number
}
interface UseWorkspacePermissionsReturn {
permissions: WorkspacePermissions | null
loading: boolean
error: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetch: () => Promise<void>
}
/**
* Custom hook to fetch and manage workspace permissions
*
* @param workspaceId - The workspace ID to fetch permissions for
* @returns Object containing permissions data, loading state, error state, and refetch function
*/
export function useWorkspacePermissions(workspaceId: string | null): UseWorkspacePermissionsReturn {
const [permissions, setPermissions] = useState<WorkspacePermissions | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchPermissions = async (id: string): Promise<void> => {
try {
setLoading(true)
setError(null)
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(id))
if (!response.ok) {
if (response.status === 404) {
throw new Error('Workspace not found or access denied')
}
if (response.status === 401) {
throw new Error('Authentication required')
}
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
}
const data: WorkspacePermissions = await response.json()
setPermissions(data)
logger.info('Workspace permissions loaded', {
workspaceId: id,
userCount: data.total,
users: data.users.map((u) => ({ email: u.email, permissions: u.permissionType })),
})
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
setError(errorMessage)
logger.error('Failed to fetch workspace permissions', {
workspaceId: id,
error: errorMessage,
})
} finally {
setLoading(false)
}
}
const updatePermissions = useCallback((newPermissions: WorkspacePermissions): void => {
setPermissions(newPermissions)
}, [])
useEffect(() => {
if (workspaceId) {
fetchPermissions(workspaceId)
} else {
// Clear state if no workspace ID
setPermissions(null)
setError(null)
setLoading(false)
}
}, [workspaceId])
const refetch = useCallback(async () => {
if (workspaceId) {
await fetchPermissions(workspaceId)
}
}, [workspaceId])
return {
permissions,
loading,
error,
updatePermissions,
refetch,
}
}

View File

@@ -30,7 +30,7 @@ import {
ensureOrganizationForTeamSubscription,
syncSubscriptionUsageLimits,
} from '@/lib/billing/organization'
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
import { getPlans } from '@/lib/billing/plans'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
@@ -2641,42 +2641,29 @@ export const auth = betterAuth({
}
},
onSubscriptionComplete: async ({
stripeSubscription,
subscription,
}: {
event: Stripe.Event
stripeSubscription: Stripe.Subscription
subscription: any
}) => {
const { priceId, planFromStripe, isTeamPlan } =
resolvePlanFromStripeSubscription(stripeSubscription)
logger.info('[onSubscriptionComplete] Subscription created', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
dbPlan: subscription.plan,
planFromStripe,
priceId,
plan: subscription.plan,
status: subscription.status,
})
const subscriptionForOrgCreation = isTeamPlan
? { ...subscription, plan: 'team' }
: subscription
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
} catch (orgError) {
logger.error(
'[onSubscriptionComplete] Failed to ensure organization for team subscription',
{
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
dbPlan: subscription.plan,
planFromStripe,
plan: subscription.plan,
error: orgError instanceof Error ? orgError.message : String(orgError),
stack: orgError instanceof Error ? orgError.stack : undefined,
}
@@ -2697,67 +2684,22 @@ export const auth = betterAuth({
event: Stripe.Event
subscription: any
}) => {
const stripeSubscription = event.data.object as Stripe.Subscription
const { priceId, planFromStripe, isTeamPlan } =
resolvePlanFromStripeSubscription(stripeSubscription)
if (priceId && !planFromStripe) {
logger.warn(
'[onSubscriptionUpdate] Could not determine plan from Stripe price ID',
{
subscriptionId: subscription.id,
priceId,
dbPlan: subscription.plan,
}
)
}
const isUpgradeToTeam =
isTeamPlan &&
subscription.plan !== 'team' &&
!subscription.referenceId.startsWith('org_')
const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan
logger.info('[onSubscriptionUpdate] Subscription updated', {
subscriptionId: subscription.id,
status: subscription.status,
dbPlan: subscription.plan,
planFromStripe,
isUpgradeToTeam,
referenceId: subscription.referenceId,
plan: subscription.plan,
})
const subscriptionForOrgCreation = isUpgradeToTeam
? { ...subscription, plan: 'team' }
: subscription
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
if (isUpgradeToTeam) {
logger.info(
'[onSubscriptionUpdate] Detected Pro -> Team upgrade, ensured organization creation',
{
subscriptionId: subscription.id,
originalPlan: subscription.plan,
newPlan: planFromStripe,
resolvedReferenceId: resolvedSubscription.referenceId,
}
)
}
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
} catch (orgError) {
logger.error(
'[onSubscriptionUpdate] Failed to ensure organization for team subscription',
{
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
dbPlan: subscription.plan,
planFromStripe,
isUpgradeToTeam,
plan: subscription.plan,
error: orgError instanceof Error ? orgError.message : String(orgError),
stack: orgError instanceof Error ? orgError.stack : undefined,
}
@@ -2775,8 +2717,9 @@ export const auth = betterAuth({
})
}
if (effectivePlanForTeamFeatures === 'team') {
if (resolvedSubscription.plan === 'team') {
try {
const stripeSubscription = event.data.object as Stripe.Subscription
const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1
const result = await syncSeatsFromStripeQuantity(

View File

@@ -1,4 +1,3 @@
import type Stripe from 'stripe'
import {
getFreeTierLimit,
getProTierLimit,
@@ -57,13 +56,6 @@ export function getPlanByName(planName: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.name === planName)
}
/**
* Get a specific plan by Stripe price ID
*/
export function getPlanByPriceId(priceId: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.priceId === priceId)
}
/**
* Get plan limits for a given plan name
*/
@@ -71,26 +63,3 @@ export function getPlanLimits(planName: string): number {
const plan = getPlanByName(planName)
return plan?.limits.cost ?? getFreeTierLimit()
}
export interface StripePlanResolution {
priceId: string | undefined
planFromStripe: string | null
isTeamPlan: boolean
}
/**
* Resolve plan information from a Stripe subscription object.
* Used to get the authoritative plan from Stripe rather than relying on DB state.
*/
export function resolvePlanFromStripeSubscription(
stripeSubscription: Stripe.Subscription
): StripePlanResolution {
const priceId = stripeSubscription?.items?.data?.[0]?.price?.id
const plan = priceId ? getPlanByPriceId(priceId) : undefined
return {
priceId,
planFromStripe: plan?.name ?? null,
isTeamPlan: plan?.name === 'team',
}
}

View File

@@ -103,7 +103,7 @@ export function getPlaceholderForFieldType(fieldType: string): string {
case 'number':
return 'Enter number'
case 'date':
return 'YYYY-MM-DD or YYYY-MM-DD HH:mm'
return 'YYYY-MM-DD'
default:
return 'Enter value'
}

View File

@@ -1,187 +0,0 @@
/**
* Tests for knowledge tag validation utility functions
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseBooleanValue, parseDateValue, parseNumberValue, validateTagValue } from './utils'
describe('Knowledge Tag Utils', () => {
describe('validateTagValue', () => {
describe('boolean validation', () => {
it('should accept "true" as valid boolean', () => {
expect(validateTagValue('isActive', 'true', 'boolean')).toBeNull()
})
it('should accept "false" as valid boolean', () => {
expect(validateTagValue('isActive', 'false', 'boolean')).toBeNull()
})
it('should accept case-insensitive boolean values', () => {
expect(validateTagValue('isActive', 'TRUE', 'boolean')).toBeNull()
expect(validateTagValue('isActive', 'FALSE', 'boolean')).toBeNull()
expect(validateTagValue('isActive', 'True', 'boolean')).toBeNull()
})
it('should reject invalid boolean values', () => {
const result = validateTagValue('isActive', 'yes', 'boolean')
expect(result).toContain('expects a boolean value')
})
})
describe('number validation', () => {
it('should accept valid integers', () => {
expect(validateTagValue('count', '42', 'number')).toBeNull()
expect(validateTagValue('count', '-10', 'number')).toBeNull()
expect(validateTagValue('count', '0', 'number')).toBeNull()
})
it('should accept valid decimals', () => {
expect(validateTagValue('price', '19.99', 'number')).toBeNull()
expect(validateTagValue('price', '-3.14', 'number')).toBeNull()
})
it('should reject non-numeric values', () => {
const result = validateTagValue('count', 'abc', 'number')
expect(result).toContain('expects a number value')
})
})
describe('date validation', () => {
it('should accept valid YYYY-MM-DD format', () => {
expect(validateTagValue('createdAt', '2024-01-15', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-12-31', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp without timezone', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T00:00:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T23:59:59', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with seconds omitted', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with UTC timezone', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00Z', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with timezone offset', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00+05:00', 'date')).toBeNull()
expect(validateTagValue('createdAt', '2024-01-15T14:30:00-08:00', 'date')).toBeNull()
})
it('should accept valid ISO 8601 timestamp with milliseconds', () => {
expect(validateTagValue('createdAt', '2024-01-15T14:30:00.123Z', 'date')).toBeNull()
})
it('should reject invalid date format', () => {
const result = validateTagValue('createdAt', '01/15/2024', 'date')
expect(result).toContain('expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format')
})
it('should reject invalid date values like Feb 31', () => {
const result = validateTagValue('createdAt', '2024-02-31', 'date')
expect(result).toContain('invalid date')
})
it('should reject invalid time values', () => {
const result = validateTagValue('createdAt', '2024-01-15T25:00:00', 'date')
expect(result).toContain('invalid time')
})
it('should reject invalid minute values', () => {
const result = validateTagValue('createdAt', '2024-01-15T12:61:00', 'date')
expect(result).toContain('invalid time')
})
})
describe('text/default validation', () => {
it('should accept any string for text type', () => {
expect(validateTagValue('name', 'anything goes', 'text')).toBeNull()
expect(validateTagValue('name', '123', 'text')).toBeNull()
expect(validateTagValue('name', '', 'text')).toBeNull()
})
})
})
describe('parseDateValue', () => {
it('should parse valid YYYY-MM-DD format', () => {
const result = parseDateValue('2024-01-15')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
expect(result?.getMonth()).toBe(0) // January is 0
expect(result?.getDate()).toBe(15)
})
it('should parse valid ISO 8601 timestamp', () => {
const result = parseDateValue('2024-01-15T14:30:00')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
expect(result?.getMonth()).toBe(0)
expect(result?.getDate()).toBe(15)
expect(result?.getHours()).toBe(14)
expect(result?.getMinutes()).toBe(30)
})
it('should parse valid ISO 8601 timestamp with UTC timezone', () => {
const result = parseDateValue('2024-01-15T14:30:00Z')
expect(result).toBeInstanceOf(Date)
expect(result?.getFullYear()).toBe(2024)
})
it('should return null for invalid format', () => {
expect(parseDateValue('01/15/2024')).toBeNull()
expect(parseDateValue('invalid')).toBeNull()
expect(parseDateValue('')).toBeNull()
})
it('should return null for invalid date values', () => {
expect(parseDateValue('2024-02-31')).toBeNull() // Feb 31 doesn't exist
expect(parseDateValue('2024-13-01')).toBeNull() // Month 13 doesn't exist
})
})
describe('parseNumberValue', () => {
it('should parse valid integers', () => {
expect(parseNumberValue('42')).toBe(42)
expect(parseNumberValue('-10')).toBe(-10)
expect(parseNumberValue('0')).toBe(0)
})
it('should parse valid decimals', () => {
expect(parseNumberValue('19.99')).toBe(19.99)
expect(parseNumberValue('-3.14')).toBeCloseTo(-3.14)
})
it('should return null for non-numeric strings', () => {
expect(parseNumberValue('abc')).toBeNull()
})
it('should return 0 for empty string (JavaScript Number behavior)', () => {
expect(parseNumberValue('')).toBe(0)
})
})
describe('parseBooleanValue', () => {
it('should parse "true" to true', () => {
expect(parseBooleanValue('true')).toBe(true)
expect(parseBooleanValue('TRUE')).toBe(true)
expect(parseBooleanValue(' true ')).toBe(true)
})
it('should parse "false" to false', () => {
expect(parseBooleanValue('false')).toBe(false)
expect(parseBooleanValue('FALSE')).toBe(false)
expect(parseBooleanValue(' false ')).toBe(false)
})
it('should return null for invalid values', () => {
expect(parseBooleanValue('yes')).toBeNull()
expect(parseBooleanValue('no')).toBeNull()
expect(parseBooleanValue('1')).toBeNull()
expect(parseBooleanValue('')).toBeNull()
})
})
})

View File

@@ -1,14 +1,3 @@
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
const ISO_WITH_TZ_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
/**
* Check if a string is a valid date format (YYYY-MM-DD or ISO 8601 timestamp)
*/
function isValidDateFormat(value: string): boolean {
return DATE_ONLY_REGEX.test(value) || DATETIME_REGEX.test(value) || ISO_WITH_TZ_REGEX.test(value)
}
/**
* Validate a tag value against its expected field type
* Returns an error message if invalid, or null if valid
@@ -32,35 +21,16 @@ export function validateTagValue(tagName: string, value: string, fieldType: stri
return null
}
case 'date': {
// Check format first - accept YYYY-MM-DD or ISO 8601 datetime
if (!isValidDateFormat(stringValue)) {
return `Tag "${tagName}" expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format, but received "${value}"`
// Check format first
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"`
}
// Extract date parts for validation
const datePart = stringValue.split('T')[0]
const [year, month, day] = datePart.split('-').map(Number)
// Validate the date is actually valid (e.g., reject 2024-02-31)
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return `Tag "${tagName}" has an invalid date: "${value}"`
}
// If timestamp is included, validate time components
if (stringValue.includes('T')) {
const timePart = stringValue.split('T')[1]
// Extract hours and minutes, ignoring timezone
const timeMatch = timePart.match(/^(\d{2}):(\d{2})/)
if (timeMatch) {
const hours = Number.parseInt(timeMatch[1], 10)
const minutes = Number.parseInt(timeMatch[2], 10)
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return `Tag "${tagName}" has an invalid time: "${value}"`
}
}
}
return null
}
default:
@@ -86,44 +56,25 @@ export function parseNumberValue(value: string): number | null {
}
/**
* Parse a string to Date with validation for YYYY-MM-DD or ISO 8601 timestamp
* Parse a string to Date with strict YYYY-MM-DD validation
* Returns null if invalid format or invalid date
*/
export function parseDateValue(value: string): Date | null {
const stringValue = String(value).trim()
// Must be valid date format
if (!isValidDateFormat(stringValue)) {
// Must be YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
return null
}
// Extract date parts
const datePart = stringValue.split('T')[0]
const [year, month, day] = datePart.split('-').map(Number)
// Validate the date is actually valid (e.g., reject 2024-02-31)
// First check date-only validity
const testDate = new Date(year, month - 1, day)
if (
testDate.getFullYear() !== year ||
testDate.getMonth() !== month - 1 ||
testDate.getDate() !== day
) {
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null
}
// If timestamp is included, parse with time
if (stringValue.includes('T')) {
// Use native Date parsing for ISO strings
const date = new Date(stringValue)
if (Number.isNaN(date.getTime())) {
return null
}
return date
}
// Date-only: return date at midnight local time
return new Date(year, month - 1, day)
return date
}
/**

View File

@@ -34,7 +34,7 @@ export function sanitizeHeaders(
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 600000,
CLIENT_TIMEOUT: 60000,
MAX_RETRIES: 3,
RECONNECT_DELAY: 1000,
} as const

View File

@@ -81,8 +81,8 @@ describe('generateMcpServerId', () => {
})
describe('MCP_CONSTANTS', () => {
it.concurrent('has correct execution timeout (10 minutes)', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(600000)
it.concurrent('has correct execution timeout', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(60000)
})
it.concurrent('has correct cache timeout (5 minutes)', () => {
@@ -107,8 +107,8 @@ describe('MCP_CONSTANTS', () => {
})
describe('MCP_CLIENT_CONSTANTS', () => {
it.concurrent('has correct client timeout (10 minutes)', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(600000)
it.concurrent('has correct client timeout', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(60000)
})
it.concurrent('has correct auto refresh interval (5 minutes)', () => {

View File

@@ -6,7 +6,7 @@ import { isMcpTool, MCP } from '@/executor/constants'
* MCP-specific constants
*/
export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: 600000,
EXECUTION_TIMEOUT: 60000,
CACHE_TIMEOUT: 5 * 60 * 1000,
DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000,
@@ -49,7 +49,7 @@ export function sanitizeHeaders(
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 600000,
CLIENT_TIMEOUT: 60000,
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
} as const

View File

@@ -1,6 +1,6 @@
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
id: 'a2a_cancel_task',

View File

@@ -1,9 +1,6 @@
import type {
A2ADeletePushNotificationParams,
A2ADeletePushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ADeletePushNotificationParams, A2ADeletePushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aDeletePushNotificationTool: ToolConfig<
A2ADeletePushNotificationParams,

View File

@@ -1,6 +1,6 @@
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
id: 'a2a_get_agent_card',

View File

@@ -1,9 +1,6 @@
import type {
A2AGetPushNotificationParams,
A2AGetPushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetPushNotificationParams, A2AGetPushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetPushNotificationTool: ToolConfig<
A2AGetPushNotificationParams,

View File

@@ -1,6 +1,6 @@
import type { A2AGetTaskParams, A2AGetTaskResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
id: 'a2a_get_task',

View File

@@ -1,6 +1,6 @@
import type { A2AResubscribeParams, A2AResubscribeResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AResubscribeParams, A2AResubscribeResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribeResponse> = {
id: 'a2a_resubscribe',

View File

@@ -1,6 +1,6 @@
import type { A2ASendMessageParams, A2ASendMessageResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
id: 'a2a_send_message',

View File

@@ -1,9 +1,6 @@
import type {
A2ASetPushNotificationParams,
A2ASetPushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ASetPushNotificationParams, A2ASetPushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aSetPushNotificationTool: ToolConfig<
A2ASetPushNotificationParams,

View File

@@ -1,5 +1,5 @@
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
import type { ToolConfig } from '@/tools/types'
import type { RunActorParams, RunActorResult } from './types'
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time

View File

@@ -1,5 +1,5 @@
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
import type { ToolConfig } from '@/tools/types'
import type { RunActorParams, RunActorResult } from './types'
export const apifyRunActorSyncTool: ToolConfig<RunActorParams, RunActorResult> = {
id: 'apify_run_actor_sync',

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsAddAliasParams,
GoogleGroupsAddAliasResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse } from './types'
export const addAliasTool: ToolConfig<GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse> = {
id: 'google_groups_add_alias',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from './types'
export const addMemberTool: ToolConfig<GoogleGroupsAddMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_add_member',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from './types'
export const createGroupTool: ToolConfig<GoogleGroupsCreateParams, GoogleGroupsResponse> = {
id: 'google_groups_create_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from './types'
export const deleteGroupTool: ToolConfig<GoogleGroupsDeleteParams, GoogleGroupsResponse> = {
id: 'google_groups_delete_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from './types'
export const getGroupTool: ToolConfig<GoogleGroupsGetParams, GoogleGroupsResponse> = {
id: 'google_groups_get_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from './types'
export const getMemberTool: ToolConfig<GoogleGroupsGetMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_get_member',

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsGetSettingsParams,
GoogleGroupsGetSettingsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetSettingsParams, GoogleGroupsGetSettingsResponse } from './types'
export const getSettingsTool: ToolConfig<
GoogleGroupsGetSettingsParams,

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from './types'
export const hasMemberTool: ToolConfig<GoogleGroupsHasMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_has_member',

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsListAliasesParams,
GoogleGroupsListAliasesResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListAliasesParams, GoogleGroupsListAliasesResponse } from './types'
export const listAliasesTool: ToolConfig<
GoogleGroupsListAliasesParams,

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsListParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListParams, GoogleGroupsResponse } from './types'
export const listGroupsTool: ToolConfig<GoogleGroupsListParams, GoogleGroupsResponse> = {
id: 'google_groups_list_groups',

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsListMembersParams,
GoogleGroupsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListMembersParams, GoogleGroupsResponse } from './types'
export const listMembersTool: ToolConfig<GoogleGroupsListMembersParams, GoogleGroupsResponse> = {
id: 'google_groups_list_members',

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsRemoveAliasParams,
GoogleGroupsRemoveAliasResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsRemoveAliasParams, GoogleGroupsRemoveAliasResponse } from './types'
export const removeAliasTool: ToolConfig<
GoogleGroupsRemoveAliasParams,

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsRemoveMemberParams,
GoogleGroupsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsRemoveMemberParams, GoogleGroupsResponse } from './types'
export const removeMemberTool: ToolConfig<GoogleGroupsRemoveMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_remove_member',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from './types'
export const updateGroupTool: ToolConfig<GoogleGroupsUpdateParams, GoogleGroupsResponse> = {
id: 'google_groups_update_group',

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsResponse,
GoogleGroupsUpdateMemberParams,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsResponse, GoogleGroupsUpdateMemberParams } from './types'
export const updateMemberTool: ToolConfig<GoogleGroupsUpdateMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_update_member',

View File

@@ -1,8 +1,5 @@
import type {
GoogleGroupsUpdateSettingsParams,
GoogleGroupsUpdateSettingsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsUpdateSettingsParams, GoogleGroupsUpdateSettingsResponse } from './types'
export const updateSettingsTool: ToolConfig<
GoogleGroupsUpdateSettingsParams,

View File

@@ -1,7 +1,7 @@
import { createHmac } from 'crypto'
import { v4 as uuidv4 } from 'uuid'
import type { RequestResponse, WebhookRequestParams } from '@/tools/http/types'
import type { ToolConfig } from '@/tools/types'
import type { RequestResponse, WebhookRequestParams } from './types'
/**
* Generates HMAC-SHA256 signature for webhook payload

View File

@@ -1,8 +1,8 @@
import type { ToolConfig } from '@/tools/types'
import type {
IncidentioIncidentStatusesListParams,
IncidentioIncidentStatusesListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
} from './types'
export const incidentStatusesListTool: ToolConfig<
IncidentioIncidentStatusesListParams,

View File

@@ -1,8 +1,8 @@
import type { ToolConfig } from '@/tools/types'
import type {
IncidentioIncidentTypesListParams,
IncidentioIncidentTypesListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
} from './types'
export const incidentTypesListTool: ToolConfig<
IncidentioIncidentTypesListParams,

View File

@@ -1,8 +1,5 @@
import type {
IncidentioSeveritiesListParams,
IncidentioSeveritiesListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioSeveritiesListParams, IncidentioSeveritiesListResponse } from './types'
export const severitiesListTool: ToolConfig<
IncidentioSeveritiesListParams,

View File

@@ -1,8 +1,5 @@
import type {
IncidentioUsersListParams,
IncidentioUsersListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioUsersListParams, IncidentioUsersListResponse } from './types'
export const usersListTool: ToolConfig<IncidentioUsersListParams, IncidentioUsersListResponse> = {
id: 'incidentio_users_list',

View File

@@ -1,8 +1,5 @@
import type {
IncidentioUsersShowParams,
IncidentioUsersShowResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioUsersShowParams, IncidentioUsersShowResponse } from './types'
export const usersShowTool: ToolConfig<IncidentioUsersShowParams, IncidentioUsersShowResponse> = {
id: 'incidentio_users_show',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from './types'
export const workflowsCreateTool: ToolConfig<WorkflowsCreateParams, WorkflowsCreateResponse> = {
id: 'incidentio_workflows_create',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from './types'
export const workflowsDeleteTool: ToolConfig<WorkflowsDeleteParams, WorkflowsDeleteResponse> = {
id: 'incidentio_workflows_delete',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsListParams, WorkflowsListResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsListParams, WorkflowsListResponse } from './types'
export const workflowsListTool: ToolConfig<WorkflowsListParams, WorkflowsListResponse> = {
id: 'incidentio_workflows_list',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsShowParams, WorkflowsShowResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsShowParams, WorkflowsShowResponse } from './types'
export const workflowsShowTool: ToolConfig<WorkflowsShowParams, WorkflowsShowResponse> = {
id: 'incidentio_workflows_show',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from './types'
export const workflowsUpdateTool: ToolConfig<WorkflowsUpdateParams, WorkflowsUpdateResponse> = {
id: 'incidentio_workflows_update',

View File

@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomGetCompany')
export interface IntercomGetCompanyParams {
accessToken: string
companyId: string

View File

@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomGetConversation')
export interface IntercomGetConversationParams {
accessToken: string
conversationId: string

View File

@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListCompanies')
export interface IntercomListCompaniesParams {
accessToken: string
per_page?: number

View File

@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListContacts')
export interface IntercomListContactsParams {
accessToken: string
per_page?: number

View File

@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListConversations')
export interface IntercomListConversationsParams {
accessToken: string
per_page?: number

View File

@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomReplyConversation')
export interface IntercomReplyConversationParams {
accessToken: string
conversationId: string

View File

@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomSearchContacts')
export interface IntercomSearchContactsParams {
accessToken: string
query: string

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiAmendOrderParams extends KalshiAuthParams {
orderId: string // Order ID to amend (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiCancelOrderParams extends KalshiAuthParams {
orderId: string // Order ID to cancel (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiCreateOrderParams extends KalshiAuthParams {
ticker: string // Market ticker (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetBalanceParams extends KalshiAuthParams {}

View File

@@ -1,6 +1,6 @@
import type { KalshiCandlestick } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiCandlestick } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetCandlesticksParams {
seriesTicker: string

View File

@@ -1,6 +1,6 @@
import type { KalshiEvent } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiEvent } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetEventParams {
eventTicker: string // Event ticker

View File

@@ -1,10 +1,6 @@
import type { KalshiEvent, KalshiPaginationParams, KalshiPagingInfo } from '@/tools/kalshi/types'
import {
buildKalshiUrl,
handleKalshiError,
KALSHI_EVENT_OUTPUT_PROPERTIES,
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiEvent, KalshiPaginationParams, KalshiPagingInfo } from './types'
import { buildKalshiUrl, handleKalshiError, KALSHI_EVENT_OUTPUT_PROPERTIES } from './types'
export interface KalshiGetEventsParams extends KalshiPaginationParams {
status?: string // open, closed, settled

View File

@@ -1,6 +1,6 @@
import type { KalshiExchangeStatus } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiExchangeStatus } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export type KalshiGetExchangeStatusParams = Record<string, never>

View File

@@ -1,16 +1,16 @@
import type { ToolConfig } from '@/tools/types'
import type {
KalshiAuthParams,
KalshiFill,
KalshiPaginationParams,
KalshiPagingInfo,
} from '@/tools/kalshi/types'
} from './types'
import {
buildKalshiAuthHeaders,
buildKalshiUrl,
handleKalshiError,
KALSHI_FILL_OUTPUT_PROPERTIES,
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
} from './types'
export interface KalshiGetFillsParams extends KalshiAuthParams, KalshiPaginationParams {
ticker?: string

View File

@@ -1,6 +1,6 @@
import type { KalshiMarket } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiMarket } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetMarketParams {
ticker: string // Market ticker

View File

@@ -1,11 +1,11 @@
import type { KalshiMarket, KalshiPaginationParams, KalshiPagingInfo } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiMarket, KalshiPaginationParams, KalshiPagingInfo } from './types'
import {
buildKalshiUrl,
handleKalshiError,
KALSHI_MARKET_OUTPUT_PROPERTIES,
KALSHI_PAGING_OUTPUT_PROPERTIES,
} from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
} from './types'
export interface KalshiGetMarketsParams extends KalshiPaginationParams {
status?: string // unopened, open, closed, settled

View File

@@ -1,6 +1,6 @@
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiAuthParams, KalshiOrder } from './types'
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetOrderParams extends KalshiAuthParams {
orderId: string // Order ID to retrieve (required)

View File

@@ -1,6 +1,6 @@
import type { KalshiOrderbook } from '@/tools/kalshi/types'
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
import type { ToolConfig } from '@/tools/types'
import type { KalshiOrderbook } from './types'
import { buildKalshiUrl, handleKalshiError } from './types'
export interface KalshiGetOrderbookParams {
ticker: string

Some files were not shown because too many files have changed in this diff Show More