diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-gateway-models.ts b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-gateway-models.ts index e16718f943..bbbae995b0 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-gateway-models.ts +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-gateway-models.ts @@ -190,19 +190,24 @@ export async function fetchGatewayModelsPage({ export async function fetchGatewayFavoriteModels( wsId: string ): Promise { - const { createClient } = await import('@tuturuuu/supabase/next/client'); - const supabase = createClient(); - const { data: favorites, error: favoritesError } = await supabase - .from('ai_model_favorites') - .select('model_id') - .eq('ws_id', wsId); + const response = await fetch( + `/api/v1/workspaces/${wsId}/ai/model-favorites`, + { + cache: 'no-store', + } + ); - if (favoritesError || !favorites?.length) return []; + if (!response.ok) return []; + + const { favoriteIds = [] } = (await response.json()) as { + favoriteIds?: string[]; + }; + if (favoriteIds.length === 0) return []; - const favoriteIds = new Set(favorites.map((favorite) => favorite.model_id)); + const favoriteIdSet = new Set(favoriteIds); const catalog = await fetchGatewayModelCatalog(); - return catalog.filter((model) => favoriteIds.has(model.value)); + return catalog.filter((model) => favoriteIdSet.has(model.value)); } export async function fetchGatewayModels(): Promise { diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-model-selector/use-mira-model-selector-data.ts b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-model-selector/use-mira-model-selector-data.ts index 7a9c8a1d62..0caae36c2c 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-model-selector/use-mira-model-selector-data.ts +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-model-selector/use-mira-model-selector-data.ts @@ -7,7 +7,10 @@ import { useQueryClient, } from '@tanstack/react-query'; import { matchesAllowedModel } from '@tuturuuu/ai/credits/model-mapping'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { + listWorkspaceAiModelFavorites, + toggleWorkspaceAiModelFavorite, +} from '@tuturuuu/internal-api/ai'; import type { AIModelUI } from '@tuturuuu/types'; import { useAiCredits } from '@tuturuuu/ui/hooks/use-ai-credits'; import { toast } from '@tuturuuu/ui/sonner'; @@ -30,14 +33,8 @@ import type { ModelFavoriteToggleHandler } from './types'; import { useSortedProviderList } from './use-provider-logo-availability'; async function fetchFavorites(wsId: string): Promise> { - const supabase = createClient(); - const { data, error } = await supabase - .from('ai_model_favorites') - .select('model_id') - .eq('ws_id', wsId); - - if (error || !data?.length) return new Set(); - return new Set(data.map((row) => row.model_id)); + const favoriteIds = await listWorkspaceAiModelFavorites(wsId); + return new Set(favoriteIds); } async function toggleFavorite( @@ -45,39 +42,7 @@ async function toggleFavorite( modelId: string, isFavorited: boolean ): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - throw new Error('Authentication required'); - } - - if (isFavorited) { - const { error } = await supabase - .from('ai_model_favorites') - .delete() - .eq('ws_id', wsId) - .eq('user_id', user.id) - .eq('model_id', modelId); - - if (error) { - throw new Error(error.message || 'Failed to update favorites'); - } - - return; - } - - const { error } = await supabase.from('ai_model_favorites').insert({ - ws_id: wsId, - user_id: user.id, - model_id: modelId, - }); - - if (error) { - throw new Error(error.message || 'Failed to update favorites'); - } + await toggleWorkspaceAiModelFavorite(wsId, { modelId, isFavorited }); } interface UseMiraModelSelectorDataParams { diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/permission-setup-banner.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/permission-setup-banner.tsx index ddc0de495d..afcda790dc 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/permission-setup-banner.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/permission-setup-banner.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Settings, ShieldAlert, X } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getWorkspacePermissionSetupStatus } from '@tuturuuu/internal-api/settings'; import { Alert, AlertDescription, AlertTitle } from '@tuturuuu/ui/alert'; import { Button } from '@tuturuuu/ui/button'; import Link from 'next/link'; @@ -23,44 +23,8 @@ export default function PermissionSetupBanner({ const { data: hasPermissions, isLoading } = useQuery({ queryKey: ['workspace-permissions-configured', wsId], - queryFn: async () => { - const supabase = createClient(); - - // Check for default permissions - const { data: defaultPerms, error: defaultError } = await supabase - .from('workspace_default_permissions') - .select('permission') - .eq('ws_id', wsId) - .eq('enabled', true) - .limit(1); - - if (defaultError) { - console.error('Error checking default permissions:', defaultError); - return true; // Don't show banner on error - } - - if (defaultPerms && defaultPerms.length > 0) { - return true; - } - - // Check for roles with permissions - const { data: roles, error: rolesError } = await supabase - .from('workspace_roles') - .select('id') - .eq('ws_id', wsId) - .limit(1); - - if (rolesError) { - console.error('Error checking roles:', rolesError); - return true; // Don't show banner on error - } - - if (roles && roles.length > 0) { - return true; - } - - return false; - }, + queryFn: async () => + (await getWorkspacePermissionSetupStatus(wsId)).hasConfiguredPermissions, staleTime: 1000 * 60 * 5, // 5 minutes }); diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/migrations/hooks/use-migration-state.ts b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/migrations/hooks/use-migration-state.ts index 9bb160b6b7..42d87660c5 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/migrations/hooks/use-migration-state.ts +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/migrations/hooks/use-migration-state.ts @@ -1,7 +1,7 @@ 'use client'; import { useLocalStorage } from '@mantine/hooks'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getWorkspace } from '@tuturuuu/internal-api/workspaces'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { MigrationModule } from '../modules'; import { @@ -347,14 +347,9 @@ export function useMigrationState( setTargetWorkspaceName(null); try { - const supabase = createClient(); - const { data, error } = await supabase - .from('workspaces') - .select('name') - .eq('id', wsId) - .single(); - - if (error || !data) { + const data = await getWorkspace(wsId); + + if (!data) { setTargetWorkspaceName(''); } else { setTargetWorkspaceName(data.name || ''); diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/index.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/index.tsx index 3ceb4200ec..e41130a7b4 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/index.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/index.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Banknote, Monitor, PencilRuler, Users } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { listWorkspaceMembers } from '@tuturuuu/internal-api/workspaces'; import type { SupabaseUser } from '@tuturuuu/supabase/next/user'; import type { PermissionId, WorkspaceRole } from '@tuturuuu/types'; import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; @@ -256,21 +256,6 @@ export function RoleForm({ wsId, user, data, forceDefault, onFinish }: Props) { } async function getWorkspaceUsers(wsId: string) { - const supabase = createClient(); - - const queryBuilder = supabase - .from('workspace_members') - .select( - 'id:user_id, ...users(display_name, ...user_private_details(email))', - { - count: 'exact', - } - ) - .eq('ws_id', wsId) - .order('user_id'); - - const { data, error, count } = await queryBuilder; - if (error) throw error; - - return { data, count } as { data: WorkspaceUser[]; count: number }; + const data = (await listWorkspaceMembers(wsId)) as WorkspaceUser[]; + return { data, count: data.length }; } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/role-members.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/role-members.tsx index 057cd7f8be..a81eb747b6 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/role-members.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/form/role-members.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { Plus, Search, User, UserPlus, Users, X } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { listRoleMembers } from '@tuturuuu/internal-api/roles'; +import { listWorkspaceMembers } from '@tuturuuu/internal-api/workspaces'; import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; import { Avatar, AvatarFallback, AvatarImage } from '@tuturuuu/ui/avatar'; import { Badge } from '@tuturuuu/ui/badge'; @@ -34,7 +35,7 @@ export default function RoleFormMembersSection({ const roleMembersQuery = useQuery({ queryKey: ['workspaces', wsId, 'roles', roleId, 'members'], - queryFn: roleId ? () => getRoleMembers(roleId) : undefined, + queryFn: roleId ? () => getRoleMembers(wsId, roleId) : undefined, enabled: !!roleId, }); @@ -339,37 +340,13 @@ export default function RoleFormMembersSection({ } async function getWorkspaceUsers(wsId: string) { - const supabase = createClient(); - - const queryBuilder = supabase - .from('workspace_members') - .select( - 'id:user_id, ...users(display_name, full_name, avatar_url, ...user_private_details(email))', - { - count: 'exact', - } - ) - .eq('ws_id', wsId) - .order('user_id'); - - const { data, error, count } = await queryBuilder; - if (error) throw error; - - return { data, count } as { data: WorkspaceUser[]; count: number }; + const data = (await listWorkspaceMembers(wsId)) as WorkspaceUser[]; + return { data, count: data.length }; } -async function getRoleMembers(roleId: string) { - const supabase = createClient(); - - const queryBuilder = supabase - .from('workspace_role_members') - .select('...users!inner(id, display_name, avatar_url)', { - count: 'exact', - }) - .eq('role_id', roleId); - - const { data, count, error } = await queryBuilder; - if (error) throw error; - - return { data, count } as { data: WorkspaceUser[]; count: number }; +async function getRoleMembers(wsId: string, roleId: string) { + return (await listRoleMembers(wsId, roleId)) as { + data: WorkspaceUser[]; + count: number; + }; } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/content-editor.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/content-editor.tsx index 9fa40c4884..d32bdb854f 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/content-editor.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/content-editor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { updateWorkspaceCourseModule } from '@tuturuuu/internal-api/education'; import type { JSONContent } from '@tuturuuu/types/tiptap'; import { toast } from '@tuturuuu/ui/sonner'; import { RichTextEditor } from '@tuturuuu/ui/text-editor/editor'; @@ -8,12 +8,18 @@ import { useTranslations } from 'next-intl'; import { useState } from 'react'; interface Props { + wsId: string; courseId: string; moduleId: string; content?: JSONContent; } -export function ModuleContentEditor({ courseId, moduleId, content }: Props) { +export function ModuleContentEditor({ + wsId, + courseId, + moduleId, + content, +}: Props) { const [post, setPost] = useState(content || null); const t = useTranslations(); @@ -23,15 +29,12 @@ export function ModuleContentEditor({ courseId, moduleId, content }: Props) { }; const saveContentToDB = async (content: JSONContent | null) => { - const supabase = createClient(); - - const { error } = await supabase - .from('workspace_course_modules') - .update({ content }) - .eq('id', moduleId) - .eq('course_id', courseId); - - if (error) { + try { + await updateWorkspaceCourseModule(wsId, moduleId, { + course_id: courseId, + content, + }); + } catch (error) { console.log(error); toast.error(t('common.error_saving_content')); } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/page.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/page.tsx index 97a2f8eed8..eaa9cce13e 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/page.tsx @@ -14,13 +14,14 @@ export const metadata: Metadata = { interface Props { params: Promise<{ + wsId: string; courseId: string; moduleId: string; }>; } export default async function ModuleContentPage({ params }: Props) { - const { courseId, moduleId } = await params; + const { wsId, courseId, moduleId } = await params; const t = await getTranslations(); const getContent = async (courseId: string, moduleId: string) => { @@ -60,6 +61,7 @@ export default async function ModuleContentPage({ params }: Props) { showSecondaryTrigger /> (null); const onDelete = async (id: string) => { - const { error } = await supabase - .from('workspace_flashcards') - .delete() - .eq('id', id); - - if (error) { + try { + await deleteWorkspaceFlashcard(wsId, id); + router.refresh(); + } catch (error) { console.log(error); - return; } - - router.refresh(); }; return ( diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/layout.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/layout.tsx index ef40d1d54d..53497702e5 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/layout.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/layout.tsx @@ -61,6 +61,7 @@ export default async function CourseDetailsLayout({ children, params }: Props) { (null); const onDelete = async (id: string) => { - const { error } = await supabase - .from('workspace_quizzes') - .delete() - .eq('id', id); - - if (error) { + try { + await deleteWorkspaceQuiz(wsId, id); + router.refresh(); + } catch (error) { console.log(error); - return; } - - router.refresh(); }; return ( diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/youtube-links/page.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/youtube-links/page.tsx index b9f12d609a..8ce3600092 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/youtube-links/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/youtube-links/page.tsx @@ -60,6 +60,7 @@ export default async function ModuleYoutubeLinksPage({ params }: Props) { className="flex flex-wrap items-center gap-2 rounded-lg border border-foreground/10 p-2 md:p-4" > & { selected?: boolean; @@ -17,15 +19,9 @@ export function QuizsetModuleLinker({ }) { const router = useRouter(); const t = useTranslations(); - const supabase = createClient(); const onSet = async (moduleIds: string[]) => { - await supabase.from('course_module_quiz_sets').upsert( - moduleIds.map((module_id) => ({ - module_id, - set_id: setId, - })) - ); + await linkQuizSetModules(wsId, setId, moduleIds); router.refresh(); }; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/page.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/page.tsx index 0e1a5a99c3..1d9df84b89 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/page.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/page.tsx @@ -56,6 +56,7 @@ export default async function WorkspaceCoursesPage({ createDescription={t('ws-course-modules.create_description')} action={ ({ ...m, diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/row-actions.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/row-actions.tsx index 2f4df8e348..9f2948fdc7 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/row-actions.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/row-actions.tsx @@ -2,7 +2,10 @@ import type { Row } from '@tanstack/react-table'; import { Ellipsis } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { + deleteWorkspaceCourseModule as deleteWorkspaceCourseModuleRequest, + unlinkQuizSetModule, +} from '@tuturuuu/internal-api/education'; import type { WorkspaceCourseModule } from '@tuturuuu/types'; import { Button } from '@tuturuuu/ui/button'; import ModifiableDialogTrigger from '@tuturuuu/ui/custom/modifiable-dialog-trigger'; @@ -34,25 +37,17 @@ export function WorkspaceCourseModuleRowActions({ }: WorkspaceCourseModuleRowActionsProps) { const router = useRouter(); const t = useTranslations(); - const supabase = createClient(); const data = row.original; const deleteWorkspaceCourseModule = async () => { - const res = await fetch( - `/api/v1/workspaces/${wsId}/course-modules/${data.id}`, - { - method: 'DELETE', - } - ); - - if (res.ok) { + try { + await deleteWorkspaceCourseModuleRequest(wsId, data.id!); router.refresh(); - } else { - const data = await res.json(); + } catch (error) { toast({ title: 'Failed to delete workspace user group tag', - description: data.message, + description: error instanceof Error ? error.message : 'Unknown error', }); } }; @@ -60,19 +55,14 @@ export function WorkspaceCourseModuleRowActions({ const unlinkWorkspaceCourseModule = async () => { if (!data.id || !setId) return; - const { error } = await supabase - .from('course_module_quiz_sets') - .delete() - .eq('module_id', data.id) - .eq('set_id', setId); - - if (error) { + try { + await unlinkQuizSetModule(wsId, setId, data.id); + router.refresh(); + } catch (error) { toast({ title: 'Failed to unlink workspace course module', - description: error.message, + description: error instanceof Error ? error.message : 'Unknown error', }); - } else { - router.refresh(); } }; diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/inventory/promotions/settings-form.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/inventory/promotions/settings-form.tsx index c1113c804c..ff8267c118 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/inventory/promotions/settings-form.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/inventory/promotions/settings-form.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMutation } from '@tanstack/react-query'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { updateWorkspaceReferralSettings } from '@tuturuuu/internal-api/promotions'; import type { Database } from '@tuturuuu/types'; import { Button } from '@tuturuuu/ui/button'; import { Combobox, type ComboboxOptions } from '@tuturuuu/ui/custom/combobox'; @@ -80,86 +80,12 @@ export default function WorkspaceSettingsForm({ const mutation = useMutation({ mutationFn: async (values: z.infer) => { - const supabase = createClient(); - const { error } = await supabase.from('workspace_settings').upsert({ - ws_id: wsId, + await updateWorkspaceReferralSettings(wsId, { referral_count_cap: values.referral_count_cap, referral_increment_percent: values.referral_increment_percent, referral_promotion_id: values.referral_promotion_id ?? null, referral_reward_type: values.referral_reward_type, }); - if (error) throw error; - - // If default referral promotion changed, migrate user_linked_promotions - const previousPromoId = row?.referral_promotion_id ?? null; - const nextPromoId = values.referral_promotion_id ?? null; - - if (previousPromoId && nextPromoId && previousPromoId !== nextPromoId) { - try { - // 1) Find users in this workspace with a referrer - const { data: referredUsers, error: usersErr } = await supabase - .from('workspace_users') - .select('id') - .eq('ws_id', wsId) - .not('referred_by', 'is', null); - if (usersErr) throw usersErr; - - const userIds = (referredUsers ?? []).map( - (u: { id: string }) => u.id - ); - if (userIds.length === 0) { - return values; - } - - // 2) Among those users, find who is currently linked to the old default promo - const { data: oldLinks, error: linksErr } = await supabase - .from('user_linked_promotions') - .select('user_id') - .eq('promo_id', previousPromoId) - .in('user_id', userIds); - if (linksErr) throw linksErr; - - const affectedUserIds = (oldLinks ?? []).map( - (l: { user_id: string }) => l.user_id - ); - if (affectedUserIds.length === 0) { - // No old links found → add link for all referred users to the new default promo - const upsertAllPayload = userIds.map((uid) => ({ - user_id: uid, - promo_id: nextPromoId, - })); - const { error: upsertAllErr } = await supabase - .from('user_linked_promotions') - .upsert(upsertAllPayload, { onConflict: 'user_id,promo_id' }); - if (upsertAllErr) throw upsertAllErr; - return values; - } - - // 3) Upsert new links for affected users to the new default promo - const upsertPayload = affectedUserIds.map((uid) => ({ - user_id: uid, - promo_id: nextPromoId, - })); - const { error: upsertErr } = await supabase - .from('user_linked_promotions') - .upsert(upsertPayload, { onConflict: 'user_id,promo_id' }); - if (upsertErr) throw upsertErr; - - // 4) Remove old links to the previous default promo for those users - const { error: deleteErr } = await supabase - .from('user_linked_promotions') - .delete() - .eq('promo_id', previousPromoId) - .in('user_id', affectedUserIds); - if (deleteErr) throw deleteErr; - } catch (e) { - // Best-effort migration: do not block settings save, surface toast via onError handler - console.error( - 'Failed to migrate referral default promotion links', - e - ); - } - } return values; }, onSuccess: () => { diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/mail/client.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/mail/client.tsx index f4dac72764..df54bc55b8 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/mail/client.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/mail/client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { listWorkspaceEmails } from '@tuturuuu/internal-api/mail'; import type { InternalEmail, User, UserPrivateDetails } from '@tuturuuu/types'; import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; import { useCallback, useEffect, useState } from 'react'; @@ -88,22 +88,5 @@ async function getWorkspaceMails( page: number = 0, pageSize: number = 20 ) { - const supabase = createClient(); - - const start = page * pageSize; - const end = start + pageSize - 1; - - const { data, error } = await supabase - .from('internal_emails') - .select('*') - .eq('ws_id', wsId) - .order('created_at', { ascending: false }) - .range(start, end); - - if (error || !data) { - console.error('Failed to fetch internal_emails', error); - return []; - } - - return data; + return listWorkspaceEmails(wsId, { page, pageSize }); } diff --git a/apps/web/src/app/[locale]/(dashboard)/[wsId]/posts/filters.tsx b/apps/web/src/app/[locale]/(dashboard)/[wsId]/posts/filters.tsx index 02ed8a9d3f..44bad90749 100644 --- a/apps/web/src/app/[locale]/(dashboard)/[wsId]/posts/filters.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/[wsId]/posts/filters.tsx @@ -1,7 +1,7 @@ 'use client'; import { MinusCircle, PlusCircle, User } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getPostsFilterOptions } from '@tuturuuu/internal-api/settings'; import type { UserGroup } from '@tuturuuu/types/primitives/UserGroup'; import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; import { useTranslations } from 'next-intl'; @@ -98,22 +98,10 @@ export default function PostsFilters({ } async function getUserGroups(wsId: string) { - const supabase = createClient(); - - const queryBuilder = supabase - .from('workspace_user_groups_with_amount') - .select('id, name, amount', { - count: 'exact', - }) - .eq('ws_id', wsId) - .order('name'); - - const { data, error, count } = await queryBuilder; - if (error) throw error; - - return { data: data || [], count: count || 0 } as { - data: UserGroup[]; - count: number; + const data = await getPostsFilterOptions(wsId); + return { + data: data.userGroups as UserGroup[], + count: data.userGroups.length, }; } @@ -121,51 +109,20 @@ async function getExcludedUserGroups( wsId: string, { includedGroups }: SearchParams ) { - const supabase = createClient(); - - if (!includedGroups || includedGroups.length === 0) { - return getUserGroups(wsId); - } - - const queryBuilder = supabase - .rpc( - 'get_possible_excluded_groups', - { - _ws_id: wsId, - included_groups: Array.isArray(includedGroups) - ? includedGroups - : [includedGroups], - }, - { - count: 'exact', - } - ) - .select('id, name, amount') - .order('name'); - - const { data, error, count } = await queryBuilder; - if (error) throw error; - - return { data: data || [], count: count || 0 } as { - data: UserGroup[]; - count: number; + const data = await getPostsFilterOptions(wsId, { + includedGroups: Array.isArray(includedGroups) + ? includedGroups + : includedGroups + ? [includedGroups] + : [], + }); + return { + data: data.excludedUserGroups as UserGroup[], + count: data.excludedUserGroups.length, }; } async function getUsers(wsId: string) { - const supabase = createClient(); - - const queryBuilder = supabase - .from('workspace_users') - .select('id, full_name') - .eq('ws_id', wsId) - .order('full_name', { ascending: true }); - - const { data, error, count } = await queryBuilder; - if (error) throw error; - - return { data: data || [], count: count || 0 } as { - data: WorkspaceUser[]; - count: number; - }; + const data = await getPostsFilterOptions(wsId); + return { data: data.users as WorkspaceUser[], count: data.users.length }; } diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/ai/model-favorites/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/ai/model-favorites/route.ts new file mode 100644 index 0000000000..054a57bd89 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/ai/model-favorites/route.ts @@ -0,0 +1,112 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const toggleFavoriteSchema = z.object({ + modelId: z.string().min(1), + isFavorited: z.boolean(), +}); + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const permissions = await getPermissions({ wsId, request }); + + if (!permissions) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { data, error } = await supabase + .from('ai_model_favorites') + .select('model_id') + .eq('ws_id', wsId) + .eq('user_id', user.id); + + if (error) { + console.error('Error fetching AI model favorites:', error); + return NextResponse.json( + { message: 'Failed to fetch AI model favorites' }, + { status: 500 } + ); + } + + return NextResponse.json({ + favoriteIds: (data ?? []).map((row) => row.model_id), + }); +} + +export async function PATCH(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const permissions = await getPermissions({ wsId, request }); + + if (!permissions) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const parsed = toggleFavoriteSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: 'Invalid request body' }, + { status: 400 } + ); + } + + const { modelId, isFavorited } = parsed.data; + + if (isFavorited) { + const { error } = await supabase + .from('ai_model_favorites') + .delete() + .eq('ws_id', wsId) + .eq('user_id', user.id) + .eq('model_id', modelId); + + if (error) { + console.error('Error removing AI model favorite:', error); + return NextResponse.json( + { message: 'Failed to update AI model favorites' }, + { status: 500 } + ); + } + } else { + const { error } = await supabase.from('ai_model_favorites').insert({ + ws_id: wsId, + user_id: user.id, + model_id: modelId, + }); + + if (error) { + console.error('Error adding AI model favorite:', error); + return NextResponse.json( + { message: 'Failed to update AI model favorites' }, + { status: 500 } + ); + } + } + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/finance/filter-users/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/filter-users/route.ts new file mode 100644 index 0000000000..a19b083d9c --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/filter-users/route.ts @@ -0,0 +1,67 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const url = new URL(request.url); + const type = url.searchParams.get('type') ?? 'all'; + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('view_transactions')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + if (type === 'transaction_creators') { + const { data, error } = await supabase + .from('distinct_transaction_creators' as never) + .select('id, display_name'); + + if (error) { + console.error('Failed to fetch transaction creators:', error); + return NextResponse.json( + { message: 'Failed to fetch transaction creators' }, + { status: 500 } + ); + } + + return NextResponse.json({ users: data ?? [] }); + } + + if (type === 'invoice_creators') { + const { data, error } = await supabase + .from('distinct_invoice_creators' as never) + .select('id, display_name'); + + if (error) { + console.error('Failed to fetch invoice creators:', error); + return NextResponse.json( + { message: 'Failed to fetch invoice creators' }, + { status: 500 } + ); + } + + return NextResponse.json({ users: data ?? [] }); + } + + const { data, error } = await supabase + .from('workspace_users') + .select('id, full_name, display_name, email, avatar_url') + .eq('ws_id', wsId) + .order('full_name', { ascending: true }); + + if (error) { + console.error('Failed to fetch workspace users:', error); + return NextResponse.json( + { message: 'Failed to fetch workspace users' }, + { status: 500 } + ); + } + + return NextResponse.json({ users: data ?? [] }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/[recurringTransactionId]/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/[recurringTransactionId]/route.ts new file mode 100644 index 0000000000..8ba8169a3b --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/[recurringTransactionId]/route.ts @@ -0,0 +1,97 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const recurringTransactionSchema = z.object({ + name: z.string().min(1), + description: z.string().nullable().optional(), + amount: z.number(), + wallet_id: z.string().min(1), + category_id: z.string().nullable().optional(), + frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly']), + start_date: z.string().min(1), + end_date: z.string().nullable().optional(), +}); + +interface Params { + params: Promise<{ wsId: string; recurringTransactionId: string }>; +} + +export async function PUT(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId, recurringTransactionId } = await params; + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('manage_finance')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const parsed = recurringTransactionSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: parsed.error.issues[0]?.message ?? 'Invalid request body' }, + { status: 400 } + ); + } + + const { data, error } = await supabase + .from('recurring_transactions') + .update(parsed.data) + .eq('ws_id', wsId) + .eq('id', recurringTransactionId) + .select('*') + .maybeSingle(); + + if (error) { + console.error('Error updating recurring transaction:', error); + return NextResponse.json( + { message: 'Failed to update recurring transaction' }, + { status: 500 } + ); + } + + if (!data) { + return NextResponse.json( + { message: 'Recurring transaction not found' }, + { status: 404 } + ); + } + + return NextResponse.json(data); +} + +export async function DELETE(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId, recurringTransactionId } = await params; + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('manage_finance')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const { data, error } = await supabase + .from('recurring_transactions') + .delete() + .eq('ws_id', wsId) + .eq('id', recurringTransactionId) + .select('id') + .maybeSingle(); + + if (error) { + console.error('Error deleting recurring transaction:', error); + return NextResponse.json( + { message: 'Failed to delete recurring transaction' }, + { status: 500 } + ); + } + + if (!data) { + return NextResponse.json( + { message: 'Recurring transaction not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/route.ts new file mode 100644 index 0000000000..55782dd3b2 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/route.ts @@ -0,0 +1,83 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const recurringTransactionSchema = z.object({ + name: z.string().min(1), + description: z.string().nullable().optional(), + amount: z.number(), + wallet_id: z.string().min(1), + category_id: z.string().nullable().optional(), + frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly']), + start_date: z.string().min(1), + end_date: z.string().nullable().optional(), +}); + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('view_transactions')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const { data, error } = await supabase + .from('recurring_transactions') + .select('*') + .eq('ws_id', wsId) + .order('next_occurrence', { ascending: true }); + + if (error) { + console.error('Error fetching recurring transactions:', error); + return NextResponse.json( + { message: 'Failed to fetch recurring transactions' }, + { status: 500 } + ); + } + + return NextResponse.json({ recurringTransactions: data ?? [] }); +} + +export async function POST(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('manage_finance')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const parsed = recurringTransactionSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: parsed.error.issues[0]?.message ?? 'Invalid request body' }, + { status: 400 } + ); + } + + const { data, error } = await supabase + .from('recurring_transactions') + .insert({ + ...parsed.data, + ws_id: wsId, + next_occurrence: parsed.data.start_date, + }) + .select('*') + .single(); + + if (error) { + console.error('Error creating recurring transaction:', error); + return NextResponse.json( + { message: 'Failed to create recurring transaction' }, + { status: 500 } + ); + } + + return NextResponse.json(data); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/upcoming/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/upcoming/route.ts new file mode 100644 index 0000000000..3f2c7015a2 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/finance/recurring-transactions/upcoming/route.ts @@ -0,0 +1,40 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const url = new URL(request.url); + const daysAhead = Number.parseInt( + url.searchParams.get('daysAhead') ?? '30', + 10 + ); + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('view_transactions')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const { data, error } = await supabase.rpc( + 'get_upcoming_recurring_transactions', + { + _ws_id: wsId, + days_ahead: Number.isNaN(daysAhead) ? 30 : daysAhead, + } + ); + + if (error) { + console.error('Error fetching upcoming recurring transactions:', error); + return NextResponse.json( + { message: 'Failed to fetch upcoming recurring transactions' }, + { status: 500 } + ); + } + + return NextResponse.json({ upcomingTransactions: data ?? [] }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/mail/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/mail/route.ts new file mode 100644 index 0000000000..94360d1ef5 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/mail/route.ts @@ -0,0 +1,43 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const url = new URL(request.url); + const page = Number.parseInt(url.searchParams.get('page') ?? '0', 10); + const pageSize = Number.parseInt( + url.searchParams.get('pageSize') ?? '20', + 10 + ); + + const permissions = await getPermissions({ wsId, request }); + if (!permissions) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const start = page * pageSize; + const end = start + pageSize - 1; + + const { data, error } = await supabase + .from('internal_emails') + .select('*') + .eq('ws_id', wsId) + .order('created_at', { ascending: false }) + .range(start, end); + + if (error) { + console.error('Failed to fetch internal emails:', error); + return NextResponse.json( + { message: 'Failed to fetch emails' }, + { status: 500 } + ); + } + + return NextResponse.json({ emails: data ?? [] }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/posts/filter-options/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/posts/filter-options/route.ts new file mode 100644 index 0000000000..d3a9ceba7f --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/posts/filter-options/route.ts @@ -0,0 +1,70 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const url = new URL(request.url); + const includedGroups = url.searchParams.getAll('includedGroups'); + + const permissions = await getPermissions({ wsId, request }); + if (!permissions) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + const userGroupsPromise = supabase + .from('workspace_user_groups_with_amount') + .select('id, name, amount', { count: 'exact' }) + .eq('ws_id', wsId) + .order('name'); + + const excludedGroupsPromise = includedGroups.length + ? supabase + .rpc( + 'get_possible_excluded_groups', + { + _ws_id: wsId, + included_groups: includedGroups, + }, + { count: 'exact' } + ) + .select('id, name, amount') + .order('name') + : userGroupsPromise; + + const usersPromise = supabase + .from('workspace_users') + .select('id, full_name') + .eq('ws_id', wsId) + .order('full_name', { ascending: true }); + + const [userGroupsResult, excludedGroupsResult, usersResult] = + await Promise.all([userGroupsPromise, excludedGroupsPromise, usersPromise]); + + if ( + userGroupsResult.error || + excludedGroupsResult.error || + usersResult.error + ) { + console.error('Error loading post filter options:', { + userGroups: userGroupsResult.error, + excludedGroups: excludedGroupsResult.error, + users: usersResult.error, + }); + return NextResponse.json( + { message: 'Failed to load filter options' }, + { status: 500 } + ); + } + + return NextResponse.json({ + userGroups: userGroupsResult.data ?? [], + excludedUserGroups: excludedGroupsResult.data ?? [], + users: usersResult.data ?? [], + }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/promotions/referral-settings/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/promotions/referral-settings/route.ts new file mode 100644 index 0000000000..563b128d54 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/promotions/referral-settings/route.ts @@ -0,0 +1,115 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const referralSettingsSchema = z.object({ + referral_count_cap: z.number().int().min(0), + referral_increment_percent: z.number().min(0), + referral_promotion_id: z.string().nullable().optional(), + referral_reward_type: z.enum(['REFERRER', 'RECEIVER', 'BOTH']), +}); + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function PUT(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const permissions = await getPermissions({ wsId, request }); + + if ( + !permissions || + permissions.withoutPermission('manage_workspace_settings') + ) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const parsed = referralSettingsSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: parsed.error.issues[0]?.message ?? 'Invalid request body' }, + { status: 400 } + ); + } + + const nextValues = parsed.data; + const { data: existingSettings } = await supabase + .from('workspace_settings') + .select('referral_promotion_id') + .eq('ws_id', wsId) + .maybeSingle(); + + const { error } = await supabase.from('workspace_settings').upsert({ + ws_id: wsId, + ...nextValues, + }); + + if (error) { + console.error('Failed to update workspace referral settings:', error); + return NextResponse.json( + { message: 'Failed to update referral settings' }, + { status: 500 } + ); + } + + const previousPromoId = existingSettings?.referral_promotion_id ?? null; + const nextPromoId = nextValues.referral_promotion_id ?? null; + + if (previousPromoId && nextPromoId && previousPromoId !== nextPromoId) { + try { + const { data: referredUsers, error: usersError } = await supabase + .from('workspace_users') + .select('id') + .eq('ws_id', wsId) + .not('referred_by', 'is', null); + + if (usersError) throw usersError; + + const userIds = (referredUsers ?? []).map((user) => user.id); + if (userIds.length > 0) { + const { data: oldLinks, error: oldLinksError } = await supabase + .from('user_linked_promotions') + .select('user_id') + .eq('promo_id', previousPromoId) + .in('user_id', userIds); + + if (oldLinksError) throw oldLinksError; + + const affectedUserIds = (oldLinks ?? []).map((link) => link.user_id); + const targetUserIds = + affectedUserIds.length > 0 ? affectedUserIds : userIds; + + const { error: upsertError } = await supabase + .from('user_linked_promotions') + .upsert( + targetUserIds.map((userId) => ({ + user_id: userId, + promo_id: nextPromoId, + })), + { onConflict: 'user_id,promo_id' } + ); + + if (upsertError) throw upsertError; + + if (affectedUserIds.length > 0) { + const { error: deleteError } = await supabase + .from('user_linked_promotions') + .delete() + .eq('promo_id', previousPromoId) + .in('user_id', affectedUserIds); + + if (deleteError) throw deleteError; + } + } + } catch (migrationError) { + console.error( + 'Failed to migrate referral default promotion links', + migrationError + ); + } + } + + return NextResponse.json({ message: 'success' }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/modules/[moduleId]/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/modules/[moduleId]/route.ts new file mode 100644 index 0000000000..3390dd60ca --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/modules/[moduleId]/route.ts @@ -0,0 +1,27 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string; setId: string; moduleId: string }>; +} + +export async function DELETE(_request: Request, { params }: Params) { + const supabase = await createClient(); + const { setId, moduleId } = await params; + + const { error } = await supabase + .from('course_module_quiz_sets') + .delete() + .eq('module_id', moduleId) + .eq('set_id', setId); + + if (error) { + console.error('Error unlinking quiz-set module:', error); + return NextResponse.json( + { message: 'Failed to unlink quiz set module' }, + { status: 500 } + ); + } + + return NextResponse.json({ message: 'success' }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/modules/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/modules/route.ts new file mode 100644 index 0000000000..114487163a --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/modules/route.ts @@ -0,0 +1,41 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const linkModulesSchema = z.object({ + moduleIds: z.array(z.string().min(1)).min(1), +}); + +interface Params { + params: Promise<{ wsId: string; setId: string }>; +} + +export async function POST(request: Request, { params }: Params) { + const supabase = await createClient(); + const { setId } = await params; + const parsed = linkModulesSchema.safeParse(await request.json()); + + if (!parsed.success) { + return NextResponse.json( + { message: 'Invalid request body' }, + { status: 400 } + ); + } + + const { error } = await supabase.from('course_module_quiz_sets').upsert( + parsed.data.moduleIds.map((moduleId) => ({ + module_id: moduleId, + set_id: setId, + })) + ); + + if (error) { + console.error('Error linking quiz-set modules:', error); + return NextResponse.json( + { message: 'Failed to link quiz set modules' }, + { status: 500 } + ); + } + + return NextResponse.json({ message: 'success' }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/roles/[roleId]/members/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/roles/[roleId]/members/route.ts index fd7a861187..0e45306a28 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/roles/[roleId]/members/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/roles/[roleId]/members/route.ts @@ -11,11 +11,14 @@ export async function GET(_: Request, { params }: Params) { const supabase = await createClient(); const { roleId } = await params; - const { data, error } = await supabase + const { data, error, count } = await supabase .from('workspace_role_members') - .select('*', { - count: 'exact', - }) + .select( + '...users!inner(id, display_name, full_name, avatar_url, ...user_private_details(email))', + { + count: 'exact', + } + ) .eq('role_id', roleId); if (error) { @@ -26,7 +29,7 @@ export async function GET(_: Request, { params }: Params) { ); } - return NextResponse.json(data); + return NextResponse.json({ data: data ?? [], count: count ?? 0 }); } export async function POST(req: Request, { params }: Params) { diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/roles/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/roles/route.ts index 51f068795b..747441bef5 100644 --- a/apps/web/src/app/api/v1/workspaces/[wsId]/roles/route.ts +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/roles/route.ts @@ -16,7 +16,7 @@ export async function GET(_: Request, { params }: Params) { .from('workspace_roles') .select('*') .eq('ws_id', wsId) - .single(); + .order('name', { ascending: true }); if (error) { console.log(error); @@ -26,7 +26,7 @@ export async function GET(_: Request, { params }: Params) { ); } - return NextResponse.json(data); + return NextResponse.json(data ?? []); } export async function POST(req: Request, { params }: Params) { diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/settings/permissions/check/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/settings/permissions/check/route.ts new file mode 100644 index 0000000000..8cc772db70 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/settings/permissions/check/route.ts @@ -0,0 +1,40 @@ +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const { wsId } = await params; + const url = new URL(request.url); + const permission = url.searchParams.get('permission'); + + if (!permission) { + return NextResponse.json( + { message: 'Missing permission' }, + { status: 400 } + ); + } + + try { + const permissions = await getPermissions({ wsId, request }); + + if (!permissions) { + return NextResponse.json( + { message: 'Workspace access denied' }, + { status: 403 } + ); + } + + return NextResponse.json({ + hasPermission: permissions.containsPermission(permission as never), + }); + } catch (error) { + console.error('Error checking workspace permission:', error); + return NextResponse.json( + { message: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/settings/permissions/setup-status/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/settings/permissions/setup-status/route.ts new file mode 100644 index 0000000000..68f528fccc --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/settings/permissions/setup-status/route.ts @@ -0,0 +1,57 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + + const permissions = await getPermissions({ wsId, request }); + if (!permissions) { + return NextResponse.json( + { message: 'Workspace access denied' }, + { status: 403 } + ); + } + + const { data: defaultPermissions, error: defaultError } = await supabase + .from('workspace_default_permissions') + .select('permission') + .eq('ws_id', wsId) + .eq('enabled', true) + .limit(1); + + if (defaultError) { + console.error('Error checking default permissions:', defaultError); + return NextResponse.json( + { message: 'Internal server error' }, + { status: 500 } + ); + } + + if ((defaultPermissions?.length ?? 0) > 0) { + return NextResponse.json({ hasConfiguredPermissions: true }); + } + + const { data: roles, error: rolesError } = await supabase + .from('workspace_roles') + .select('id') + .eq('ws_id', wsId) + .limit(1); + + if (rolesError) { + console.error('Error checking roles:', rolesError); + return NextResponse.json( + { message: 'Internal server error' }, + { status: 500 } + ); + } + + return NextResponse.json({ + hasConfiguredPermissions: (roles?.length ?? 0) > 0, + }); +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/storage/object/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/storage/object/route.ts new file mode 100644 index 0000000000..3ae7a512d1 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/storage/object/route.ts @@ -0,0 +1,67 @@ +import { + createClient, + createDynamicAdminClient, +} from '@tuturuuu/supabase/next/server'; +import { + getPermissions, + normalizeWorkspaceId, +} from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const deleteObjectSchema = z.object({ + path: z.string().min(1), +}); + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ wsId: string }> } +) { + const { wsId } = await params; + const supabase = await createClient(request); + const normalizedWsId = await normalizeWorkspaceId(wsId, supabase); + const permissions = await getPermissions({ wsId: normalizedWsId, request }); + + if (!permissions) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { message: 'Invalid request body' }, + { status: 400 } + ); + } + + const parsed = deleteObjectSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { message: 'Invalid request body' }, + { status: 400 } + ); + } + + const rawPath = parsed.data.path; + const prefix = `${normalizedWsId}/`; + const storagePath = rawPath.startsWith(prefix) + ? rawPath + : `${normalizedWsId}/${rawPath}`; + + const sbAdmin = await createDynamicAdminClient(); + const { error } = await sbAdmin.storage + .from('workspaces') + .remove([storagePath]); + + if (error) { + console.error('Failed to delete storage object:', error); + return NextResponse.json( + { message: 'Failed to delete storage object' }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/api/workspaces/[wsId]/transactions/category-breakdown/route.ts b/apps/web/src/app/api/workspaces/[wsId]/transactions/category-breakdown/route.ts new file mode 100644 index 0000000000..63ff000afe --- /dev/null +++ b/apps/web/src/app/api/workspaces/[wsId]/transactions/category-breakdown/route.ts @@ -0,0 +1,46 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const url = new URL(request.url); + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('view_transactions')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const walletId = url.searchParams.get('walletId'); + const startDate = url.searchParams.get('startDate'); + const endDate = url.searchParams.get('endDate'); + const type = url.searchParams.get('type') ?? 'expense'; + const timezone = url.searchParams.get('timezone') ?? 'UTC'; + + const { data, error } = await supabase.rpc('get_category_breakdown', { + _ws_id: wsId, + _start_date: startDate || undefined, + _end_date: endDate || undefined, + include_confidential: true, + _transaction_type: type, + _interval: 'daily', + _anchor_to_latest: false, + _timezone: timezone, + _wallet_ids: walletId ? [walletId] : undefined, + }); + + if (error) { + console.error('Error fetching category breakdown:', error); + return NextResponse.json( + { message: 'Failed to fetch category breakdown' }, + { status: 500 } + ); + } + + return NextResponse.json(data ?? []); +} diff --git a/apps/web/src/app/api/workspaces/[wsId]/transactions/spending-trends/route.ts b/apps/web/src/app/api/workspaces/[wsId]/transactions/spending-trends/route.ts new file mode 100644 index 0000000000..514b4754ee --- /dev/null +++ b/apps/web/src/app/api/workspaces/[wsId]/transactions/spending-trends/route.ts @@ -0,0 +1,67 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { getPermissions } from '@tuturuuu/utils/workspace-helper'; +import { format } from 'date-fns'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ wsId: string }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(request); + const { wsId } = await params; + const url = new URL(request.url); + const days = Number.parseInt(url.searchParams.get('days') ?? '30', 10); + + const permissions = await getPermissions({ wsId, request }); + if (!permissions || permissions.withoutPermission('view_transactions')) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + const startDate = new Date(); + startDate.setDate(startDate.getDate() - (Number.isNaN(days) ? 30 : days)); + + const { data, error } = await supabase + .from('wallet_transactions') + .select( + ` + amount, + taken_at, + workspace_wallets!inner(ws_id) + ` + ) + .eq('workspace_wallets.ws_id', wsId) + .lt('amount', 0) + .gte('taken_at', startDate.toISOString()) + .order('taken_at', { ascending: true }); + + if (error) { + console.error('Error fetching spending trends:', error); + return NextResponse.json( + { message: 'Failed to fetch spending trends' }, + { status: 500 } + ); + } + + const totalDays = Number.isNaN(days) ? 30 : days; + const dailySpending = new Map(); + + for (let i = 0; i < totalDays; i += 1) { + const date = new Date(); + date.setDate(date.getDate() - (totalDays - 1 - i)); + dailySpending.set(format(date, 'yyyy-MM-dd'), 0); + } + + for (const transaction of data ?? []) { + const dateKey = format(new Date(transaction.taken_at), 'yyyy-MM-dd'); + const amount = Math.abs(Number(transaction.amount)); + dailySpending.set(dateKey, (dailySpending.get(dateKey) ?? 0) + amount); + } + + return NextResponse.json( + Array.from(dailySpending.entries()).map(([date, amount]) => ({ + date, + amount, + })) + ); +} diff --git a/apps/web/src/lib/calendar-preferences-provider.tsx b/apps/web/src/lib/calendar-preferences-provider.tsx index d4f340bd23..659f38cd05 100644 --- a/apps/web/src/lib/calendar-preferences-provider.tsx +++ b/apps/web/src/lib/calendar-preferences-provider.tsx @@ -1,7 +1,8 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getWorkspaceCalendarSettings } from '@tuturuuu/internal-api/settings'; +import { getUserCalendarSettings } from '@tuturuuu/internal-api/users'; import { type CalendarPreferences, CalendarPreferencesProvider as UICalendarPreferencesProvider, @@ -23,58 +24,18 @@ export function CalendarPreferencesProvider({ wsId, }: CalendarPreferencesProviderProps) { const locale = useLocale(); - const supabase = createClient(); - const [userId, setUserId] = React.useState(null); - - React.useEffect(() => { - supabase.auth.getSession().then(({ data: { session } }) => { - setUserId(session?.user?.id ?? null); - }); - - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, session) => { - setUserId(session?.user?.id ?? null); - }); - - return () => subscription.unsubscribe(); - }, [supabase]); // Fetch user calendar settings const { data: userSettings } = useQuery({ - queryKey: ['users', 'calendar-settings', userId], - queryFn: async () => { - if (!userId) return null; - const res = await fetch('/api/v1/users/calendar-settings', { - cache: 'no-store', - }); - if (!res.ok) return null; - const data = await res.json(); - return data as { - timezone?: string | null; - first_day_of_week?: string | null; - time_format?: string | null; - }; - }, - enabled: !!userId, + queryKey: ['users', 'calendar-settings'], + queryFn: async () => getUserCalendarSettings(), staleTime: 5 * 60 * 1000, // 5 minutes }); // Fetch workspace calendar settings (if wsId is provided) const { data: workspaceSettings } = useQuery({ queryKey: ['workspace-calendar-settings', wsId], - queryFn: async () => { - if (!wsId) return null; - const res = await fetch(`/api/v1/workspaces/${wsId}/calendar-settings`, { - cache: 'no-store', - }); - if (!res.ok) return null; - const data = await res.json(); - return data as { - timezone?: string | null; - first_day_of_week?: string | null; - }; - }, + queryFn: async () => (wsId ? getWorkspaceCalendarSettings(wsId) : null), enabled: !!wsId, staleTime: 5 * 60 * 1000, // 5 minutes }); diff --git a/packages/internal-api/package.json b/packages/internal-api/package.json index e0f24d2454..5511db567d 100644 --- a/packages/internal-api/package.json +++ b/packages/internal-api/package.json @@ -12,9 +12,16 @@ }, "exports": { ".": "./src/index.ts", + "./ai": "./src/ai.ts", "./client": "./src/client.ts", "./calendar": "./src/calendar.ts", + "./education": "./src/education.ts", "./finance": "./src/finance.ts", + "./mail": "./src/mail.ts", + "./promotions": "./src/promotions.ts", + "./roles": "./src/roles.ts", + "./settings": "./src/settings.ts", + "./storage": "./src/storage.ts", "./tasks": "./src/tasks.ts", "./tasks-scheduling": "./src/tasks-scheduling.ts", "./templates": "./src/templates.ts", diff --git a/packages/internal-api/src/ai.ts b/packages/internal-api/src/ai.ts new file mode 100644 index 0000000000..13304ed8c8 --- /dev/null +++ b/packages/internal-api/src/ai.ts @@ -0,0 +1,39 @@ +import { + encodePathSegment, + getInternalApiClient, + type InternalApiClientOptions, +} from './client'; + +export async function listWorkspaceAiModelFavorites( + workspaceId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + const payload = await client.json<{ favoriteIds: string[] }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/ai/model-favorites`, + { + cache: 'no-store', + } + ); + + return payload.favoriteIds ?? []; +} + +export async function toggleWorkspaceAiModelFavorite( + workspaceId: string, + payload: { modelId: string; isFavorited: boolean }, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ success: true }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/ai/model-favorites`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + } + ); +} diff --git a/packages/internal-api/src/education.ts b/packages/internal-api/src/education.ts new file mode 100644 index 0000000000..a365839c7f --- /dev/null +++ b/packages/internal-api/src/education.ts @@ -0,0 +1,125 @@ +import { + encodePathSegment, + getInternalApiClient, + type InternalApiClientOptions, +} from './client'; + +export async function updateWorkspaceCourseModule( + workspaceId: string, + moduleId: string, + payload: Record, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ message: string }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/course-modules/${encodePathSegment(moduleId)}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + } + ); +} + +export async function deleteWorkspaceCourseModule( + workspaceId: string, + moduleId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ message: string }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/course-modules/${encodePathSegment(moduleId)}`, + { + method: 'DELETE', + cache: 'no-store', + } + ); +} + +export async function linkQuizSetModules( + workspaceId: string, + setId: string, + moduleIds: string[], + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ message: string }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/quiz-sets/${encodePathSegment(setId)}/modules`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ moduleIds }), + cache: 'no-store', + } + ); +} + +export async function unlinkQuizSetModule( + workspaceId: string, + setId: string, + moduleId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ message: string }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/quiz-sets/${encodePathSegment(setId)}/modules/${encodePathSegment(moduleId)}`, + { + method: 'DELETE', + cache: 'no-store', + } + ); +} + +export async function deleteWorkspaceQuiz( + workspaceId: string, + quizId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ message: string }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/quizzes/${encodePathSegment(quizId)}`, + { + method: 'DELETE', + cache: 'no-store', + } + ); +} + +export async function deleteWorkspaceFlashcard( + workspaceId: string, + flashcardId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ message: string }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/flashcards/${encodePathSegment(flashcardId)}`, + { + method: 'DELETE', + cache: 'no-store', + } + ); +} + +export async function deleteWorkspaceStorageObject( + workspaceId: string, + path: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ success: true }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/storage/object`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ path }), + cache: 'no-store', + } + ); +} diff --git a/packages/internal-api/src/finance.ts b/packages/internal-api/src/finance.ts index c6f0d5b62a..8cb81124a0 100644 --- a/packages/internal-api/src/finance.ts +++ b/packages/internal-api/src/finance.ts @@ -8,6 +8,7 @@ import { encodePathSegment, getInternalApiClient, type InternalApiClientOptions, + type InternalApiQuery, } from './client'; export async function listWallets( @@ -75,6 +76,24 @@ export interface FinanceBudgetUpsertPayload { wallet_id?: string | null; } +export interface RecurringTransactionPayload { + name: string; + description?: string | null; + amount: number; + wallet_id: string; + category_id?: string | null; + frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'; + start_date: string; + end_date?: string | null; +} + +export interface RecurringTransactionRecord + extends RecurringTransactionPayload { + id: string; + next_occurrence: string; + is_active: boolean; +} + export async function createBudget( workspaceId: string, payload: FinanceBudgetUpsertPayload, @@ -141,3 +160,139 @@ export async function listTransactionCategories( } ); } + +export async function listRecurringTransactions( + workspaceId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + const payload = await client.json<{ + recurringTransactions: RecurringTransactionRecord[]; + }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/finance/recurring-transactions`, + { + cache: 'no-store', + } + ); + + return payload.recurringTransactions ?? []; +} + +export async function listUpcomingRecurringTransactions( + workspaceId: string, + query?: { daysAhead?: number }, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + const payload = await client.json<{ upcomingTransactions: unknown[] }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/finance/recurring-transactions/upcoming`, + { + query, + cache: 'no-store', + } + ); + + return payload.upcomingTransactions ?? []; +} + +export async function createRecurringTransaction( + workspaceId: string, + payload: RecurringTransactionPayload, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/finance/recurring-transactions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + } + ); +} + +export async function updateRecurringTransaction( + workspaceId: string, + recurringTransactionId: string, + payload: RecurringTransactionPayload, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/finance/recurring-transactions/${encodePathSegment(recurringTransactionId)}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + } + ); +} + +export async function deleteRecurringTransaction( + workspaceId: string, + recurringTransactionId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ success: true }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/finance/recurring-transactions/${encodePathSegment(recurringTransactionId)}`, + { + method: 'DELETE', + cache: 'no-store', + } + ); +} + +export async function getTransactionStats( + workspaceId: string, + query?: InternalApiQuery, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ + totalTransactions: number; + totalIncome: number; + totalExpense: number; + netTotal: number; + hasRedactedAmounts: boolean; + }>(`/api/workspaces/${encodePathSegment(workspaceId)}/transactions/stats`, { + query, + cache: 'no-store', + }); +} + +export async function getCategoryBreakdown( + workspaceId: string, + query?: InternalApiQuery, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json( + `/api/workspaces/${encodePathSegment(workspaceId)}/transactions/category-breakdown`, + { + query, + cache: 'no-store', + } + ); +} + +export async function getSpendingTrends( + workspaceId: string, + query?: InternalApiQuery, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json>( + `/api/workspaces/${encodePathSegment(workspaceId)}/transactions/spending-trends`, + { + query, + cache: 'no-store', + } + ); +} diff --git a/packages/internal-api/src/index.ts b/packages/internal-api/src/index.ts index 6c9eb71cd7..9cc1559a50 100644 --- a/packages/internal-api/src/index.ts +++ b/packages/internal-api/src/index.ts @@ -1,3 +1,7 @@ +export { + listWorkspaceAiModelFavorites, + toggleWorkspaceAiModelFavorite, +} from './ai'; export { applyWorkspaceCalendarSchedule, createWorkspaceCalendarEvent, @@ -30,17 +34,51 @@ export { resolveInternalApiUrl, withForwardedInternalApiAuth, } from './client'; +export { + deleteWorkspaceCourseModule, + deleteWorkspaceFlashcard, + deleteWorkspaceQuiz, + deleteWorkspaceStorageObject, + linkQuizSetModules, + unlinkQuizSetModule, + updateWorkspaceCourseModule, +} from './education'; export type { FinanceBudgetUpsertPayload } from './finance'; export { createBudget, + createRecurringTransaction, deleteBudget, + deleteRecurringTransaction, getBudgetStatus, + getCategoryBreakdown, + getSpendingTrends, + getTransactionStats, getWallet, listBudgets, + listRecurringTransactions, listTransactionCategories, + listUpcomingRecurringTransactions, listWallets, + type RecurringTransactionPayload, + type RecurringTransactionRecord, updateBudget, + updateRecurringTransaction, } from './finance'; +export { listWorkspaceEmails } from './mail'; +export { + updateWorkspaceReferralSettings, + type WorkspaceReferralSettingsPayload, +} from './promotions'; +export { listRoleMembers, listWorkspaceRoles } from './roles'; +export { + checkWorkspacePermission, + getPostsFilterOptions, + getWorkspaceCalendarHours, + getWorkspaceCalendarSettings, + getWorkspacePermissionSetupStatus, + getWorkspacePermissionsSummary, + updateWorkspaceCalendarHours, +} from './settings'; export { createWorkspaceStorageSignedUrl, uploadWorkspaceStorageFile, @@ -90,6 +128,8 @@ export { updateWorkspaceBreakType, } from './time-tracking'; export { + getCurrentUserProfile, + getUserCalendarSettings, getUserConfig, updateUserConfig, } from './users'; diff --git a/packages/internal-api/src/mail.ts b/packages/internal-api/src/mail.ts new file mode 100644 index 0000000000..a38823a2d3 --- /dev/null +++ b/packages/internal-api/src/mail.ts @@ -0,0 +1,23 @@ +import type { InternalEmail } from '@tuturuuu/types'; +import { + encodePathSegment, + getInternalApiClient, + type InternalApiClientOptions, +} from './client'; + +export async function listWorkspaceEmails( + workspaceId: string, + query?: { page?: number; pageSize?: number }, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + const payload = await client.json<{ emails: InternalEmail[] }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/mail`, + { + query, + cache: 'no-store', + } + ); + + return payload.emails ?? []; +} diff --git a/packages/internal-api/src/promotions.ts b/packages/internal-api/src/promotions.ts new file mode 100644 index 0000000000..f780b80dbc --- /dev/null +++ b/packages/internal-api/src/promotions.ts @@ -0,0 +1,31 @@ +import { + encodePathSegment, + getInternalApiClient, + type InternalApiClientOptions, +} from './client'; + +export interface WorkspaceReferralSettingsPayload { + referral_count_cap: number; + referral_increment_percent: number; + referral_promotion_id?: string | null; + referral_reward_type: 'REFERRER' | 'RECEIVER' | 'BOTH'; +} + +export async function updateWorkspaceReferralSettings( + workspaceId: string, + payload: WorkspaceReferralSettingsPayload, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ message: string }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/promotions/referral-settings`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + } + ); +} diff --git a/packages/internal-api/src/roles.ts b/packages/internal-api/src/roles.ts new file mode 100644 index 0000000000..b1c299d3ea --- /dev/null +++ b/packages/internal-api/src/roles.ts @@ -0,0 +1,43 @@ +import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; +import { + encodePathSegment, + getInternalApiClient, + type InternalApiClientOptions, +} from './client'; + +type WorkspaceRoleSummary = { + id: string; + name: string; +}; + +export async function listWorkspaceRoles( + workspaceId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/roles`, + { + cache: 'no-store', + } + ); +} + +export async function listRoleMembers( + workspaceId: string, + roleId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + const payload = await client.json<{ + data: WorkspaceUser[]; + count: number; + }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/roles/${encodePathSegment(roleId)}/members`, + { + cache: 'no-store', + } + ); + + return payload; +} diff --git a/packages/internal-api/src/settings.ts b/packages/internal-api/src/settings.ts new file mode 100644 index 0000000000..6d9613f3ca --- /dev/null +++ b/packages/internal-api/src/settings.ts @@ -0,0 +1,135 @@ +import { + encodePathSegment, + getInternalApiClient, + type InternalApiClientOptions, +} from './client'; + +export interface WorkspacePermissionSetupStatus { + hasConfiguredPermissions: boolean; +} + +export interface WorkspacePermissionsSummary { + manage_subscription: boolean; + manage_workspace_settings: boolean; +} + +export async function getWorkspacePermissionSetupStatus( + workspaceId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/settings/permissions/setup-status`, + { + cache: 'no-store', + } + ); +} + +export async function getWorkspacePermissionsSummary( + workspaceId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/settings/permissions`, + { + cache: 'no-store', + } + ); +} + +export async function checkWorkspacePermission( + workspaceId: string, + permission: string, + userId?: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ hasPermission: boolean }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/settings/permissions/check`, + { + query: { + permission, + userId, + }, + cache: 'no-store', + } + ); +} + +export async function getWorkspaceCalendarHours( + workspaceId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ + personalHours: Record; + workHours: Record; + meetingHours: Record; + }>(`/api/v1/workspaces/${encodePathSegment(workspaceId)}/calendar-hours`, { + cache: 'no-store', + }); +} + +export async function getWorkspaceCalendarSettings( + workspaceId: string, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ + timezone?: string | null; + first_day_of_week?: string | null; + }>(`/api/v1/workspaces/${encodePathSegment(workspaceId)}/calendar-settings`, { + cache: 'no-store', + }); +} + +export async function updateWorkspaceCalendarHours( + workspaceId: string, + payload: { type: 'PERSONAL' | 'WORK' | 'MEETING'; hours: unknown }, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ success: true }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/calendar-hours`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + } + ); +} + +export async function getPostsFilterOptions( + workspaceId: string, + query?: { includedGroups?: string[] }, + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + const search = new URLSearchParams(); + + for (const groupId of query?.includedGroups ?? []) { + search.append('includedGroups', groupId); + } + + const suffix = search.toString() ? `?${search.toString()}` : ''; + + return client.json<{ + userGroups: Array<{ id: string; name: string | null; amount: number }>; + excludedUserGroups: Array<{ + id: string; + name: string | null; + amount: number; + }>; + users: Array<{ id: string; full_name: string | null }>; + }>( + `/api/v1/workspaces/${encodePathSegment(workspaceId)}/posts/filter-options${suffix}`, + { + cache: 'no-store', + } + ); +} diff --git a/packages/internal-api/src/users.ts b/packages/internal-api/src/users.ts index f22ee7e51e..c639dac890 100644 --- a/packages/internal-api/src/users.ts +++ b/packages/internal-api/src/users.ts @@ -8,6 +8,16 @@ type UserConfigResponse = { value: string | null; }; +export type CurrentUserProfileResponse = { + id: string; + email: string | null; + display_name: string | null; + avatar_url: string | null; + full_name: string | null; + new_email: string | null; + created_at: string; +}; + export async function getUserConfig( configId: string, options?: InternalApiClientOptions @@ -38,3 +48,25 @@ export async function updateUserConfig( } ); } + +export async function getCurrentUserProfile( + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json('/api/v1/users/me/profile', { + cache: 'no-store', + }); +} + +export async function getUserCalendarSettings( + options?: InternalApiClientOptions +) { + const client = getInternalApiClient(options); + return client.json<{ + timezone?: string | null; + first_day_of_week?: string | null; + time_format?: string | null; + }>('/api/v1/users/calendar-settings', { + cache: 'no-store', + }); +} diff --git a/packages/ui/src/components/ui/custom/education/modules/module-toggle.tsx b/packages/ui/src/components/ui/custom/education/modules/module-toggle.tsx index f23daef070..794e283ae9 100644 --- a/packages/ui/src/components/ui/custom/education/modules/module-toggle.tsx +++ b/packages/ui/src/components/ui/custom/education/modules/module-toggle.tsx @@ -1,23 +1,24 @@ 'use client'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { updateWorkspaceCourseModule } from '@tuturuuu/internal-api/education'; import { Checkbox } from '@tuturuuu/ui/checkbox'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; export function ModuleToggles({ + wsId, courseId, moduleId, // isPublic: initialIsPublic, isPublished: initialIsPublished, }: { + wsId: string; courseId: string; moduleId: string; isPublic: boolean; isPublished: boolean; }) { const t = useTranslations(); - const supabase = createClient(); const [loading, setLoading] = useState(false); // const [isPublic, setIsPublic] = useState(initialIsPublic); @@ -42,18 +43,17 @@ export function ModuleToggles({ // }; const handlePublishedChange = async (checked: boolean) => { - const { error } = await supabase - .from('workspace_course_modules') - .update({ is_published: checked }) - .eq('course_id', courseId) - .eq('id', moduleId); - - if (error) { + setLoading(true); + try { + await updateWorkspaceCourseModule(wsId, moduleId, { + course_id: courseId, + is_published: checked, + }); + setIsPublished(checked); + } catch { setLoading(false); - throw error; + throw new Error('Failed to update module'); } - - setIsPublished(checked); setLoading(false); }; diff --git a/packages/ui/src/components/ui/custom/education/modules/resources/delete-resource.tsx b/packages/ui/src/components/ui/custom/education/modules/resources/delete-resource.tsx index fcefa71464..5430696e1c 100644 --- a/packages/ui/src/components/ui/custom/education/modules/resources/delete-resource.tsx +++ b/packages/ui/src/components/ui/custom/education/modules/resources/delete-resource.tsx @@ -1,7 +1,7 @@ 'use client'; import { Trash } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { deleteWorkspaceStorageObject } from '@tuturuuu/internal-api/education'; import { Button } from '@tuturuuu/ui/button'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -16,13 +16,17 @@ export function DeleteResourceButton({ path }: { path: string }) { const deleteResource = async (path: string) => { setLoading(true); - const supabase = createClient(); + const wsId = path.split('/')[0]; - const { error } = await supabase.storage.from('workspaces').remove([path]); + if (!wsId) { + setLoading(false); + return; + } - if (!error) { + try { + await deleteWorkspaceStorageObject(wsId, path); router.refresh(); - } else { + } catch { setLoading(false); } }; diff --git a/packages/ui/src/components/ui/custom/education/modules/resources/file-display.tsx b/packages/ui/src/components/ui/custom/education/modules/resources/file-display.tsx index 8c89940837..35fdbf8456 100644 --- a/packages/ui/src/components/ui/custom/education/modules/resources/file-display.tsx +++ b/packages/ui/src/components/ui/custom/education/modules/resources/file-display.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { createWorkspaceStorageSignedUrl } from '@tuturuuu/internal-api/storage'; import dynamic from 'next/dynamic'; import Image from 'next/image'; import { useTranslations } from 'next-intl'; @@ -30,7 +30,6 @@ export function FileDisplay({ }) { const t = useTranslations(); - const supabase = createClient(); const [signedUrl, setSignedUrl] = useState(null); useEffect(() => { @@ -38,21 +37,25 @@ export function FileDisplay({ if (!file.id || !file.name) return; const fullPath = `${path.endsWith('/') ? path : `${path}/`}${file.name}`; - const { data, error } = await supabase.storage - .from('workspaces') - .createSignedUrl(fullPath, 3600); + const wsId = fullPath.split('/')[0]; + if (!wsId) return; + const relativePath = fullPath.slice(wsId.length + 1); - if (error) { + try { + const nextSignedUrl = await createWorkspaceStorageSignedUrl( + wsId, + relativePath, + 3600 + ); + setSignedUrl(nextSignedUrl); + } catch (error) { console.error(error); return; } - console.log(data); - - setSignedUrl(data?.signedUrl); }; fetchSignedUrl(); - }, [file.id, file.name, path, supabase.storage]); + }, [file.id, file.name, path]); if (!signedUrl) return null; diff --git a/packages/ui/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx b/packages/ui/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx index 7843e84bea..0ac70a1e2d 100644 --- a/packages/ui/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +++ b/packages/ui/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx @@ -1,17 +1,19 @@ 'use client'; import { Trash } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { updateWorkspaceCourseModule } from '@tuturuuu/internal-api/education'; import { Button } from '@tuturuuu/ui/button'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; export default function DeleteLinkButton({ + wsId, moduleId, courseId, link, links, }: { + wsId: string; moduleId: string; courseId: string; link: string; @@ -26,24 +28,19 @@ export default function DeleteLinkButton({ links: string[] ) => { setLoading(true); - const supabase = createClient(); - - const { data, error } = await supabase - .from('workspace_course_modules') - .update({ + try { + await updateWorkspaceCourseModule(wsId, moduleId, { + course_id: courseId, youtube_links: links, - }) - .eq('id', moduleId) - .eq('course_id', courseId); - - if (error) { + }); + router.refresh(); + setLoading(false); + return null; + } catch (error) { console.error('error', error); setLoading(false); - } else { - router.refresh(); + return null; } - - return data; }; return ( diff --git a/packages/ui/src/components/ui/finance/analytics/category-spending-chart.tsx b/packages/ui/src/components/ui/finance/analytics/category-spending-chart.tsx index dc0d71f66c..c7d0fd45b3 100644 --- a/packages/ui/src/components/ui/finance/analytics/category-spending-chart.tsx +++ b/packages/ui/src/components/ui/finance/analytics/category-spending-chart.tsx @@ -1,7 +1,7 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getCategoryBreakdown } from '@tuturuuu/internal-api/finance'; import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; import { type ChartConfig, @@ -42,43 +42,21 @@ export function CategorySpendingChart({ currency = 'USD', }: CategorySpendingChartProps) { const locale = useLocale(); - const supabase = createClient(); const { data: categoryData, isLoading } = useQuery({ queryKey: ['category_spending', wsId, startDate, endDate], queryFn: async () => { - let query = supabase - .from('wallet_transactions') - .select( - ` - amount, - category_id, - transaction_categories(name), - workspace_wallets!inner(ws_id) - ` - ) - .eq('workspace_wallets.ws_id', wsId) - .lt('amount', 0); // Only expenses + const data = (await getCategoryBreakdown(wsId, { + startDate, + endDate, + type: 'expense', + })) as Array<{ category_name: string | null; total: number }>; - if (startDate) { - query = query.gte('taken_at', startDate); - } - - if (endDate) { - query = query.lte('taken_at', endDate); - } - - const { data, error } = await query; - - if (error) throw error; - - // Group by category and sum amounts const categoryMap = new Map(); - data?.forEach((transaction: any) => { - const categoryName = - transaction.transaction_categories?.name || 'Uncategorized'; - const amount = Math.abs(Number(transaction.amount)); + data?.forEach((transaction) => { + const categoryName = transaction.category_name || 'Uncategorized'; + const amount = Math.abs(Number(transaction.total)); categoryMap.set( categoryName, @@ -128,7 +106,7 @@ export function CategorySpendingChart({ cx="50%" cy="50%" labelLine={false} - label={(props: any) => + label={(props) => `${props.name}: ${((props.percent || 0) * 100).toFixed(0)}%` } outerRadius={80} diff --git a/packages/ui/src/components/ui/finance/analytics/spending-trends-chart.tsx b/packages/ui/src/components/ui/finance/analytics/spending-trends-chart.tsx index fa279612ef..af220e4f17 100644 --- a/packages/ui/src/components/ui/finance/analytics/spending-trends-chart.tsx +++ b/packages/ui/src/components/ui/finance/analytics/spending-trends-chart.tsx @@ -1,7 +1,7 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getSpendingTrends } from '@tuturuuu/internal-api/finance'; import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; import { type ChartConfig, @@ -34,57 +34,13 @@ export function SpendingTrendsChart({ currency = 'USD', }: SpendingTrendsChartProps) { const locale = useLocale(); - const supabase = createClient(); const { resolvedTheme } = useTheme(); const expenseColor = resolvedTheme === 'dark' ? '#f87171' : '#dc2626'; const { data: trendsData, isLoading } = useQuery({ queryKey: ['spending_trends', wsId], - queryFn: async () => { - // Get last 30 days of spending - const startDate = new Date(); - startDate.setDate(startDate.getDate() - 30); - - const { data, error } = await supabase - .from('wallet_transactions') - .select( - ` - amount, - taken_at, - workspace_wallets!inner(ws_id) - ` - ) - .eq('workspace_wallets.ws_id', wsId) - .lt('amount', 0) // Only expenses - .gte('taken_at', startDate.toISOString()) - .order('taken_at', { ascending: true }); - - if (error) throw error; - - // Group by day - const dailySpending = new Map(); - - // Initialize all days with 0 - for (let i = 0; i < 30; i++) { - const date = new Date(); - date.setDate(date.getDate() - (29 - i)); - const dateStr = format(date, 'yyyy-MM-dd'); - dailySpending.set(dateStr, 0); - } - - // Add actual spending - data?.forEach((transaction: any) => { - const dateStr = format(new Date(transaction.taken_at), 'yyyy-MM-dd'); - const amount = Math.abs(Number(transaction.amount)); - dailySpending.set(dateStr, (dailySpending.get(dateStr) || 0) + amount); - }); - - return Array.from(dailySpending.entries()).map(([date, amount]) => ({ - date, - amount, - })); - }, + queryFn: async () => getSpendingTrends(wsId, { days: 30 }), }); const chartConfig = { diff --git a/packages/ui/src/components/ui/finance/recurring/form.tsx b/packages/ui/src/components/ui/finance/recurring/form.tsx index 3dfa5933c0..81036178e9 100644 --- a/packages/ui/src/components/ui/finance/recurring/form.tsx +++ b/packages/ui/src/components/ui/finance/recurring/form.tsx @@ -2,8 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { listWallets } from '@tuturuuu/internal-api/finance'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { + createRecurringTransaction, + listTransactionCategories, + listWallets, + updateRecurringTransaction, +} from '@tuturuuu/internal-api/finance'; import { Button } from '@tuturuuu/ui/button'; import { Form, @@ -66,7 +70,6 @@ export function RecurringTransactionForm({ onSuccess, }: RecurringTransactionFormProps) { const queryClient = useQueryClient(); - const supabase = createClient(); const isEditing = !!data?.id; const { data: wallets } = useQuery({ @@ -76,16 +79,7 @@ export function RecurringTransactionForm({ const { data: categories } = useQuery({ queryKey: ['categories', wsId], - queryFn: async () => { - const { data, error } = await supabase - .from('transaction_categories') - .select('id, name') - .eq('ws_id', wsId) - .order('name'); - - if (error) throw error; - return data; - }, + queryFn: async () => listTransactionCategories(wsId), }); const form = useForm({ @@ -116,7 +110,6 @@ export function RecurringTransactionForm({ const onSubmit = async (formData: RecurringFormValues) => { try { const transactionData = { - ws_id: wsId, name: formData.name, description: formData.description || null, amount: parseFloat(formData.amount), @@ -128,22 +121,10 @@ export function RecurringTransactionForm({ }; if (isEditing && data) { - const { error } = await supabase - .from('recurring_transactions') - .update(transactionData) - .eq('id', data.id); - - if (error) throw error; + await updateRecurringTransaction(wsId, data.id, transactionData); toast.success('Recurring transaction updated successfully'); } else { - const { error } = await supabase.from('recurring_transactions').insert([ - { - ...transactionData, - next_occurrence: formData.start_date, - }, - ]); - - if (error) throw error; + await createRecurringTransaction(wsId, transactionData); toast.success('Recurring transaction created successfully'); } @@ -246,11 +227,18 @@ export function RecurringTransactionForm({ - {categories?.map((category) => ( - - {category.name} - - ))} + {categories + ?.filter( + ( + category + ): category is typeof category & { id: string } => + typeof category.id === 'string' + ) + .map((category) => ( + + {category.name} + + ))} diff --git a/packages/ui/src/components/ui/finance/recurring/recurring-transactions-page.tsx b/packages/ui/src/components/ui/finance/recurring/recurring-transactions-page.tsx index 5e8116129f..4bd60eb606 100644 --- a/packages/ui/src/components/ui/finance/recurring/recurring-transactions-page.tsx +++ b/packages/ui/src/components/ui/finance/recurring/recurring-transactions-page.tsx @@ -2,7 +2,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Calendar, Ellipsis, Plus, RefreshCw } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { + deleteRecurringTransaction as deleteRecurringTransactionRequest, + listRecurringTransactions, + listUpcomingRecurringTransactions, +} from '@tuturuuu/internal-api/finance'; import { AlertDialog, AlertDialogAction, @@ -64,48 +68,24 @@ export default function RecurringTransactionsPage({ const [editingTransaction, setEditingTransaction] = useState(null); const [deletingId, setDeletingId] = useState(null); - const supabase = createClient(); const queryClient = useQueryClient(); const { data: recurringTransactions, isLoading } = useQuery({ queryKey: ['recurring_transactions', wsId], - queryFn: async () => { - const { data, error } = await supabase - .from('recurring_transactions') - .select('*') - .eq('ws_id', wsId) - .order('next_occurrence', { ascending: true }); - - if (error) throw error; - return data as RecurringTransaction[]; - }, + queryFn: async () => + (await listRecurringTransactions(wsId)) as RecurringTransaction[], }); const { data: upcomingTransactions } = useQuery({ queryKey: ['upcoming_recurring_transactions', wsId], - queryFn: async () => { - const { data, error } = await supabase.rpc( - 'get_upcoming_recurring_transactions', - { - _ws_id: wsId, - days_ahead: 30, - } - ); - - if (error) throw error; - return data; - }, + queryFn: async () => + listUpcomingRecurringTransactions(wsId, { daysAhead: 30 }), }); - const deleteRecurringTransaction = async (id: string) => { + const handleDeleteRecurringTransaction = async (id: string) => { setDeletingId(id); try { - const { error } = await supabase - .from('recurring_transactions') - .delete() - .eq('id', id); - - if (error) throw error; + await deleteRecurringTransactionRequest(wsId, id); toast.success('Recurring transaction deleted successfully'); queryClient.invalidateQueries({ @@ -267,7 +247,7 @@ export default function RecurringTransactionsPage({ - deleteRecurringTransaction( + handleDeleteRecurringTransaction( transaction.id ) } diff --git a/packages/ui/src/components/ui/finance/transactions/category-filter.tsx b/packages/ui/src/components/ui/finance/transactions/category-filter.tsx index 101ceec0f1..f1de406794 100644 --- a/packages/ui/src/components/ui/finance/transactions/category-filter.tsx +++ b/packages/ui/src/components/ui/finance/transactions/category-filter.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { ArrowDownCircle, ArrowUpCircle, Check, Tag, X } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { listTransactionCategories } from '@tuturuuu/internal-api/finance'; import { Badge } from '@tuturuuu/ui/badge'; import { Button } from '@tuturuuu/ui/button'; import { @@ -43,20 +43,19 @@ interface CategoryFilterProps { async function fetchTransactionCategories( wsId: string ): Promise { - const supabase = createClient(); - const { data, error } = await supabase - .from('transaction_categories') - .select('id, name, is_expense, icon, color') - .eq('ws_id', wsId) - .order('name', { ascending: true }); - - if (error) throw error; - return (data || []).map((cat) => ({ - ...cat, - is_expense: cat.is_expense ?? false, - icon: cat.icon ?? null, - color: cat.color ?? null, - })); + const data = await listTransactionCategories(wsId); + return (data || []) + .filter( + (cat): cat is typeof cat & { id: string; name: string } => + typeof cat.id === 'string' && typeof cat.name === 'string' + ) + .map((cat) => ({ + id: cat.id, + name: cat.name, + is_expense: cat.is_expense ?? false, + icon: cat.icon ?? null, + color: cat.color ?? null, + })); } export function CategoryFilter({ diff --git a/packages/ui/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx b/packages/ui/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx index 50e8e17eb1..3e684cec48 100644 --- a/packages/ui/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +++ b/packages/ui/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Check, Eye, EyeOff, RotateCcw } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getCategoryBreakdown } from '@tuturuuu/internal-api/finance'; import type { Transaction } from '@tuturuuu/types/primitives/Transaction'; import { convertCurrency } from '@tuturuuu/utils/exchange-rates'; import { cn } from '@tuturuuu/utils/format'; @@ -331,20 +331,13 @@ export function CategoryBreakdownDialog({ // Fetch category breakdown for a given transaction type const fetchCategoryBreakdown = async (txType: string) => { - const supabase = createClient(); - const { data, error } = await supabase.rpc('get_category_breakdown', { - _ws_id: workspaceId, - _start_date: periodStart, - _end_date: periodEnd, - include_confidential: true, - _transaction_type: txType, - _interval: 'daily', - _anchor_to_latest: false, - _timezone: timezone, - _wallet_ids: walletId ? [walletId] : undefined, - }); - if (error) throw error; - return data as CategoryBreakdownData[]; + return (await getCategoryBreakdown(workspaceId, { + walletId, + startDate: periodStart, + endDate: periodEnd, + type: txType, + timezone, + })) as CategoryBreakdownData[]; }; const queryBase = { diff --git a/packages/ui/src/components/ui/finance/transactions/period-charts/category-donut-chart.tsx b/packages/ui/src/components/ui/finance/transactions/period-charts/category-donut-chart.tsx index 3f86090007..b3bd813a8c 100644 --- a/packages/ui/src/components/ui/finance/transactions/period-charts/category-donut-chart.tsx +++ b/packages/ui/src/components/ui/finance/transactions/period-charts/category-donut-chart.tsx @@ -1,7 +1,7 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getCategoryBreakdown } from '@tuturuuu/internal-api/finance'; import type { Transaction } from '@tuturuuu/types/primitives/Transaction'; import { convertCurrency } from '@tuturuuu/utils/exchange-rates'; import { cn } from '@tuturuuu/utils/format'; @@ -186,7 +186,6 @@ export function CategoryDonutChart({ timezone, ], queryFn: async () => { - const supabase = createClient(); // Create start/end timestamps in the user's timezone // IMPORTANT: Use the resolved timezone, not UTC, to ensure we query the correct local day const startDate = dayjs @@ -196,20 +195,13 @@ export function CategoryDonutChart({ .tz(`${periodEndDate || periodStartDate} 23:59:59.999`, timezone) .toISOString(); - const { data, error } = await supabase.rpc('get_category_breakdown', { - _ws_id: workspaceId!, // Safe: query is only enabled when workspaceId exists - _start_date: startDate, - _end_date: endDate, - include_confidential: true, - _transaction_type: type, - _interval: 'daily', - _anchor_to_latest: false, - _timezone: timezone, // Pass timezone for correct date grouping - _wallet_ids: walletId ? [walletId] : undefined, - }); - - if (error) throw error; - return data as { + const data = (await getCategoryBreakdown(workspaceId!, { + walletId, + startDate, + endDate, + type, + timezone, + })) as { period: string; category_id: string | null; category_name: string; @@ -217,6 +209,7 @@ export function CategoryDonutChart({ category_color: string | null; total: number; }[]; + return data; }, staleTime: 2 * 60 * 1000, // 2 minute cache refetchOnWindowFocus: false, diff --git a/packages/ui/src/components/ui/finance/transactions/period-charts/period-breakdown-panel.tsx b/packages/ui/src/components/ui/finance/transactions/period-charts/period-breakdown-panel.tsx index ba9d3be72a..5d75baa608 100644 --- a/packages/ui/src/components/ui/finance/transactions/period-charts/period-breakdown-panel.tsx +++ b/packages/ui/src/components/ui/finance/transactions/period-charts/period-breakdown-panel.tsx @@ -8,7 +8,7 @@ import { TrendingDown, TrendingUp, } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getTransactionStats } from '@tuturuuu/internal-api/finance'; import type { Transaction } from '@tuturuuu/types/primitives/Transaction'; import type { TransactionViewMode } from '@tuturuuu/types/primitives/TransactionPeriod'; import { convertCurrency } from '@tuturuuu/utils/exchange-rates'; @@ -192,7 +192,6 @@ export function PeriodBreakdownPanel({ timezone, ], queryFn: async () => { - const supabase = createClient(); // Create start/end timestamps in the user's timezone // IMPORTANT: Use the resolved timezone, not UTC, to ensure we query the correct local day const startDate = dayjs @@ -202,16 +201,11 @@ export function PeriodBreakdownPanel({ .tz(`${computedPeriodEnd} 23:59:59.999`, timezone) .toISOString(); - const { data, error } = await supabase.rpc('get_transaction_stats', { - p_ws_id: workspaceId!, // Safe: query is only enabled when workspaceId exists - p_start_date: startDate, - p_end_date: endDate, + const row = await getTransactionStats(workspaceId!, { + start: startDate, + end: endDate, }); - if (error) throw error; - - // RPC returns an array with one row - const row = data?.[0]; if (!row) { return { totalIncome: 0, @@ -223,11 +217,11 @@ export function PeriodBreakdownPanel({ } return { - totalIncome: Number(row.total_income) || 0, - totalExpense: Number(row.total_expense) || 0, - netTotal: Number(row.net_total) || 0, - transactionCount: Number(row.total_transactions) || 0, - hasRedactedAmounts: row.has_redacted_amounts || false, + totalIncome: Number(row.totalIncome) || 0, + totalExpense: Number(row.totalExpense) || 0, + netTotal: Number(row.netTotal) || 0, + transactionCount: Number(row.totalTransactions) || 0, + hasRedactedAmounts: row.hasRedactedAmounts || false, }; }, staleTime: 2 * 60 * 1000, // 2 minute cache diff --git a/packages/ui/src/components/ui/finance/transactions/user-filter.tsx b/packages/ui/src/components/ui/finance/transactions/user-filter.tsx index f11b4c8edc..fc3866c56e 100644 --- a/packages/ui/src/components/ui/finance/transactions/user-filter.tsx +++ b/packages/ui/src/components/ui/finance/transactions/user-filter.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Check, Users, X } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { listWorkspaceMembers } from '@tuturuuu/internal-api/workspaces'; import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; import { Avatar, AvatarFallback, AvatarImage } from '@tuturuuu/ui/avatar'; import { Button } from '@tuturuuu/ui/button'; @@ -40,34 +40,33 @@ async function fetchWorkspaceUsers( wsId: string, filterType: 'all' | 'transaction_creators' | 'invoice_creators' = 'all' ): Promise { - const supabase = await createClient(); - if (filterType === 'transaction_creators') { - const { data, error } = await supabase - .from('distinct_transaction_creators' as any) - .select('id, display_name'); + const response = await fetch( + `/api/v1/workspaces/${wsId}/finance/filter-users?type=transaction_creators`, + { + cache: 'no-store', + } + ); - if (error) throw error; - return (data as unknown as WorkspaceUser[]) || []; + if (!response.ok) throw new Error('Failed to fetch transaction creators'); + const data = (await response.json()) as { users?: WorkspaceUser[] }; + return data.users || []; } if (filterType === 'invoice_creators') { - const { data, error } = await supabase - .from('distinct_invoice_creators' as any) - .select('id, display_name'); + const response = await fetch( + `/api/v1/workspaces/${wsId}/finance/filter-users?type=invoice_creators`, + { + cache: 'no-store', + } + ); - if (error) throw error; - return (data as unknown as WorkspaceUser[]) || []; + if (!response.ok) throw new Error('Failed to fetch invoice creators'); + const data = (await response.json()) as { users?: WorkspaceUser[] }; + return data.users || []; } - const { data, error } = await supabase - .from('workspace_users') - .select('id, full_name, display_name, email, avatar_url') - .eq('ws_id', wsId) - .order('full_name', { ascending: true }); - - if (error) throw error; - return data || []; + return (await listWorkspaceMembers(wsId)) as WorkspaceUser[]; } export function UserFilter({ diff --git a/packages/ui/src/components/ui/finance/wallets/walletId/wallet-role-access.tsx b/packages/ui/src/components/ui/finance/wallets/walletId/wallet-role-access.tsx index 94f27c7af8..2e708915d9 100644 --- a/packages/ui/src/components/ui/finance/wallets/walletId/wallet-role-access.tsx +++ b/packages/ui/src/components/ui/finance/wallets/walletId/wallet-role-access.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, Plus, Search, Shield, Trash2 } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { listWorkspaceRoles } from '@tuturuuu/internal-api/roles'; import type { WorkspaceRoleWalletWhitelist } from '@tuturuuu/types/primitives/WorkspaceRoleWalletWhitelist'; import { AlertDialog, @@ -578,13 +578,5 @@ async function getRoleAccess(wsId: string, walletId: string) { } async function getAvailableRoles(wsId: string) { - const supabase = createClient(); - const { data, error } = await supabase - .from('workspace_roles') - .select('id, name') - .eq('ws_id', wsId) - .order('name', { ascending: true }); - - if (error) throw error; - return data || []; + return listWorkspaceRoles(wsId); } diff --git a/packages/ui/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx b/packages/ui/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx index eaa2b71bba..833f558f35 100644 --- a/packages/ui/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +++ b/packages/ui/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx @@ -343,8 +343,26 @@ export function GoogleCalendarSettings({ } // Also delete all calendar connections for this workspace - const supabase = createClient(); - await supabase.from('calendar_connections').delete().eq('ws_id', wsId); + const connectionsResponse = await fetch( + `/api/v1/calendar/connections?wsId=${wsId}`, + { + cache: 'no-store', + } + ); + + if (connectionsResponse.ok) { + const { connections } = (await connectionsResponse.json()) as { + connections?: Array<{ id: string }>; + }; + + await Promise.all( + (connections ?? []).map((connection) => + fetch(`/api/v1/calendar/connections?id=${connection.id}`, { + method: 'DELETE', + }) + ) + ); + } toast({ title: 'Disconnected', diff --git a/packages/ui/src/components/ui/legacy/calendar/settings/hour-settings.tsx b/packages/ui/src/components/ui/legacy/calendar/settings/hour-settings.tsx index 0a48d771fc..02c58b5241 100644 --- a/packages/ui/src/components/ui/legacy/calendar/settings/hour-settings.tsx +++ b/packages/ui/src/components/ui/legacy/calendar/settings/hour-settings.tsx @@ -1,7 +1,10 @@ 'use client'; import { Briefcase, Calendar, Clock, User } from '@tuturuuu/icons'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { + getWorkspaceCalendarHours, + updateWorkspaceCalendarHours, +} from '@tuturuuu/internal-api/settings'; import { Badge } from '@tuturuuu/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; import { toast } from '@tuturuuu/ui/sonner'; @@ -32,77 +35,25 @@ export function HoursSettings({ wsId }: HoursSettingsProps) { useEffect(() => { const fetchHours = async () => { - const supabase = createClient(); - - const { data, error } = await supabase - .from('workspace_calendar_hour_settings') - .select('*') - .eq('ws_id', wsId); - - if (error) { + try { + const data = await getWorkspaceCalendarHours(wsId); + setValue({ + personalHours: isValidWeekTimeRanges(data.personalHours) + ? data.personalHours + : defaultWeekTimeRanges, + workHours: isValidWeekTimeRanges(data.workHours) + ? data.workHours + : defaultWeekTimeRanges, + meetingHours: isValidWeekTimeRanges(data.meetingHours) + ? data.meetingHours + : defaultWeekTimeRanges, + }); + } catch (error) { console.error('Error fetching hours:', error); toast.error( 'Failed to load hour settings. Please refresh or try again later.' ); - return; - } - - // If no data exists, create default settings - if (!data || data.length === 0) { - const makeDefaultData = () => structuredClone(defaultWeekTimeRanges); - const defaultSettings = [ - { - type: 'PERSONAL' as const, - data: JSON.stringify(makeDefaultData()), - ws_id: wsId, - }, - { - type: 'WORK' as const, - data: JSON.stringify(makeDefaultData()), - ws_id: wsId, - }, - { - type: 'MEETING' as const, - data: JSON.stringify(makeDefaultData()), - ws_id: wsId, - }, - ]; - - const { error: insertError } = await supabase - .from('workspace_calendar_hour_settings') - .insert(defaultSettings) - .select(); - - if (insertError) { - console.error('Error creating default settings:', insertError); - return; - } - - setValue({ - personalHours: defaultWeekTimeRanges, - workHours: defaultWeekTimeRanges, - meetingHours: defaultWeekTimeRanges, - }); - return; } - - setValue({ - personalHours: isValidWeekTimeRanges( - safeParse(data?.find((h) => h.type === 'PERSONAL')?.data) - ) - ? safeParse(data?.find((h) => h.type === 'PERSONAL')?.data) - : defaultWeekTimeRanges, - workHours: isValidWeekTimeRanges( - safeParse(data?.find((h) => h.type === 'WORK')?.data) - ) - ? safeParse(data?.find((h) => h.type === 'WORK')?.data) - : defaultWeekTimeRanges, - meetingHours: isValidWeekTimeRanges( - safeParse(data?.find((h) => h.type === 'MEETING')?.data) - ) - ? safeParse(data?.find((h) => h.type === 'MEETING')?.data) - : defaultWeekTimeRanges, - }); }; fetchHours(); @@ -125,26 +76,17 @@ export function HoursSettings({ wsId }: HoursSettingsProps) { personalHours: newHours, })); - const supabase = createClient(); - - const { error } = await supabase - .from('workspace_calendar_hour_settings') - .upsert( - { - data: JSON.stringify(newHours), - type: 'PERSONAL', - ws_id: wsId, - }, - { onConflict: 'ws_id,type' } - ); - - if (error) { + try { + await updateWorkspaceCalendarHours(wsId, { + type: 'PERSONAL', + hours: newHours, + }); + toast.success('Personal hours updated'); + } catch (error) { console.error('Error updating personal hours:', error); toast.error('Failed to update personal hours'); return; } - - toast.success('Personal hours updated'); }; const handleWorkHoursChange = async (newHours?: WeekTimeRanges | null) => { @@ -158,26 +100,17 @@ export function HoursSettings({ wsId }: HoursSettingsProps) { workHours: newHours, })); - const supabase = createClient(); - - const { error } = await supabase - .from('workspace_calendar_hour_settings') - .upsert( - { - data: JSON.stringify(newHours), - type: 'WORK', - ws_id: wsId, - }, - { onConflict: 'ws_id,type' } - ); - - if (error) { + try { + await updateWorkspaceCalendarHours(wsId, { + type: 'WORK', + hours: newHours, + }); + toast.success('Work hours updated'); + } catch (error) { console.error('Error updating work hours:', error); toast.error('Failed to update work hours'); return; } - - toast.success('Work hours updated'); }; const handleMeetingHoursChange = async (newHours?: WeekTimeRanges | null) => { @@ -191,26 +124,17 @@ export function HoursSettings({ wsId }: HoursSettingsProps) { meetingHours: newHours, })); - const supabase = createClient(); - - const { error } = await supabase - .from('workspace_calendar_hour_settings') - .upsert( - { - data: JSON.stringify(newHours), - type: 'MEETING', - ws_id: wsId, - }, - { onConflict: 'ws_id,type' } - ); - - if (error) { + try { + await updateWorkspaceCalendarHours(wsId, { + type: 'MEETING', + hours: newHours, + }); + toast.success('Meeting hours updated'); + } catch (error) { console.error('Error updating meeting hours:', error); toast.error('Failed to update meeting hours'); return; } - - toast.success('Meeting hours updated'); }; // Helper to get a summary of active days @@ -342,9 +266,12 @@ export function HoursSettings({ wsId }: HoursSettingsProps) { ); } +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + // Type guard for WeekTimeRanges -function isValidWeekTimeRanges(obj: any): obj is WeekTimeRanges { - if (!obj || typeof obj !== 'object') return false; +function isValidWeekTimeRanges(obj: unknown): obj is WeekTimeRanges { + if (!isRecord(obj)) return false; const days = [ 'monday', 'tuesday', @@ -354,22 +281,12 @@ function isValidWeekTimeRanges(obj: any): obj is WeekTimeRanges { 'saturday', 'sunday', ]; - return days.every( - (day) => - obj[day] && - typeof obj[day].enabled === 'boolean' && - Array.isArray(obj[day].timeBlocks) - ); -} + return days.every((day) => { + const entry = obj[day]; + if (!isRecord(entry)) return false; -// Safe JSON parse helper -function safeParse(data: any): any { - if (typeof data === 'string') { - try { - return JSON.parse(data); - } catch { - return undefined; - } - } - return data; + return ( + typeof entry.enabled === 'boolean' && Array.isArray(entry.timeBlocks) + ); + }); } diff --git a/packages/ui/src/hooks/use-workspace-permission.ts b/packages/ui/src/hooks/use-workspace-permission.ts index df9cce5642..80473ed1b6 100644 --- a/packages/ui/src/hooks/use-workspace-permission.ts +++ b/packages/ui/src/hooks/use-workspace-permission.ts @@ -1,7 +1,7 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { checkWorkspacePermission } from '@tuturuuu/internal-api/settings'; import type { PermissionId } from '@tuturuuu/types'; import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; @@ -40,34 +40,14 @@ export function useWorkspacePermission({ enabled = true, user, }: UseWorkspacePermissionOptions): UseWorkspacePermissionReturn { - const supabase = createClient(); - const { data: hasPermission, isLoading, error, } = useQuery({ queryKey: ['workspace-permission', wsId, permission, user.id], - queryFn: async () => { - // Explicit "no user" case: user is unauthenticated (not a transient error) - // Check permission via RPC – throw on error (transient failures surface properly) - const { data, error: rpcError } = await supabase.rpc( - 'has_workspace_permission', - { - p_user_id: user.id, - p_ws_id: wsId, - p_permission: permission, - } - ); - - if (rpcError) { - throw new Error( - `Failed to check workspace permission: ${rpcError.message}` - ); - } - - return data ?? false; - }, + queryFn: async () => + (await checkWorkspacePermission(wsId, permission, user.id)).hasPermission, enabled: enabled && !!wsId && !!user, staleTime: 5 * 60 * 1000, // Cache for 5 minutes retry: 1, // Retry once on failure diff --git a/packages/ui/src/hooks/use-workspace-user.ts b/packages/ui/src/hooks/use-workspace-user.ts index 923a534451..beaf444f94 100644 --- a/packages/ui/src/hooks/use-workspace-user.ts +++ b/packages/ui/src/hooks/use-workspace-user.ts @@ -1,7 +1,7 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { createClient } from '@tuturuuu/supabase/next/client'; +import { getCurrentUserProfile } from '@tuturuuu/internal-api/users'; import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser'; /** @@ -14,40 +14,15 @@ export function useWorkspaceUser() { return useQuery({ queryKey: ['workspace-user'], queryFn: async (): Promise => { - const supabase = createClient(); - - // Get current authenticated user - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser(); - - if (authError || !user) { - throw new Error('Not authenticated'); - } - - // Fetch user data with private details - const { data, error } = await supabase - .from('users') - .select( - 'id, display_name, avatar_url, bio, handle, created_at, user_private_details(email, new_email, birthday, full_name, default_workspace_id)' - ) - .eq('id', user.id) - .single(); - - if (error) { - throw new Error(`Failed to fetch user: ${error.message}`); - } - - if (!data) { - throw new Error('User data not found'); - } - - // Merge user data with private details - const { user_private_details, ...rest } = data; + const data = await getCurrentUserProfile(); return { - ...rest, - ...user_private_details, + id: data.id, + email: data.email, + display_name: data.display_name, + avatar_url: data.avatar_url, + full_name: data.full_name, + created_at: data.created_at, + new_email: data.new_email, } as WorkspaceUser; }, staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes diff --git a/scripts/internal-api-migration.test.js b/scripts/internal-api-migration.test.js index 7da087bfe2..a5da3d5255 100644 --- a/scripts/internal-api-migration.test.js +++ b/scripts/internal-api-migration.test.js @@ -26,6 +26,34 @@ test('migrated shared hooks no longer import the deprecated Supabase browser cli 'apps/tasks/src/components/settings/settings-dialog.tsx', 'packages/ui/src/hooks/use-user-config.ts', 'packages/ui/src/hooks/use-workspace-members.ts', + 'packages/ui/src/hooks/use-workspace-user.ts', + 'packages/ui/src/hooks/use-workspace-permission.ts', + 'apps/web/src/lib/calendar-preferences-provider.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/inventory/promotions/settings-form.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/posts/filters.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/mail/client.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/permission-setup-banner.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/(dashboard)/components/mira-model-selector/use-mira-model-selector-data.ts', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/linker.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/education/quiz-sets/[setId]/linked-modules/row-actions.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/content/content-editor.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx', + 'apps/web/src/app/[locale]/(dashboard)/[wsId]/education/courses/[courseId]/modules/[moduleId]/flashcards/client-flashcards.tsx', + 'packages/ui/src/components/ui/custom/education/modules/module-toggle.tsx', + 'packages/ui/src/components/ui/custom/education/modules/resources/delete-resource.tsx', + 'packages/ui/src/components/ui/custom/education/modules/resources/file-display.tsx', + 'packages/ui/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx', + 'packages/ui/src/components/ui/finance/transactions/category-filter.tsx', + 'packages/ui/src/components/ui/finance/transactions/user-filter.tsx', + 'packages/ui/src/components/ui/finance/transactions/period-charts/category-donut-chart.tsx', + 'packages/ui/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx', + 'packages/ui/src/components/ui/finance/transactions/period-charts/period-breakdown-panel.tsx', + 'packages/ui/src/components/ui/finance/analytics/spending-trends-chart.tsx', + 'packages/ui/src/components/ui/finance/analytics/category-spending-chart.tsx', + 'packages/ui/src/components/ui/finance/recurring/form.tsx', + 'packages/ui/src/components/ui/finance/recurring/recurring-transactions-page.tsx', + 'packages/ui/src/components/ui/finance/wallets/walletId/wallet-role-access.tsx', + 'packages/ui/src/components/ui/legacy/calendar/settings/hour-settings.tsx', 'apps/track/src/app/[locale]/(dashboard)/[wsId]/components/workspace-select-dialog.tsx', ];