From 87fe60f82a015d3a6b91e0ed5e41ed51bb63a5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Tue, 23 Jun 2026 16:00:22 +0200 Subject: [PATCH] chore: checkpoint before features reorganization --- .eslintrc.json | 6 +- app/api/avatar/route.tsx | 51 ---- src/features/auth/social-buttons.tsx | 154 ++++++------ src/features/layout/logged-in-button.tsx | 2 +- .../db-settings/storage-section.tsx | 2 +- src/features/onboarding/onboarding-state.ts | 39 ++- src/features/onboarding/onboarding-steps.tsx | 6 +- .../onboarding/steps/step-db-settings.tsx | 4 +- .../onboarding/steps/step-defaults.tsx | 111 +++++---- src/features/onboarding/steps/step-login.tsx | 2 +- .../onboarding/steps/step-preferences.tsx | 86 +------ .../onboarding/steps/step-storage.tsx | 4 +- src/features/onboarding/types/index.ts | 1 - src/features/profile/avatar-with-upload.tsx | 161 +++++++------ .../settings/avatar-mode-selector.tsx | 4 +- src/features/settings/avatar-section.tsx | 2 +- .../settings/dicebear-style-picker.tsx | 7 +- src/features/settings/settings-tabs.tsx | 88 +++---- src/lib/github-releases.ts | 228 +++++++++++------- src/lib/logger.ts | 70 +++--- src/lib/tasks/cleaning/index.ts | 51 ++-- src/lib/tasks/database/index.ts | 89 ++++--- src/lib/tasks/database/utils/delete.ts | 185 +++++++------- src/lib/twx.tsx | 2 +- src/lib/utils.ts | 6 +- src/middleware/errorHandler.ts | 15 +- src/middleware/loggingMiddleware.ts | 24 +- src/types/action-type.ts | 43 ++-- src/types/auth.ts | 27 +-- src/types/common.ts | 2 +- src/types/next.ts | 8 +- src/utils/common.ts | 154 ++++++------ src/utils/init/setting.ts | 120 +++++---- src/utils/init/user.ts | 109 ++++----- 34 files changed, 930 insertions(+), 933 deletions(-) delete mode 100644 app/api/avatar/route.tsx diff --git a/.eslintrc.json b/.eslintrc.json index bab62ad2..4284c946 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,11 +1,7 @@ { - "extends": [ - "next/core-web-vitals", - "plugin:tailwindcss/recommended" - ], + "extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"], "rules": { "react/no-unescaped-entities": "off", "react-hooks/rules-of-hooks": "off" - } } diff --git a/app/api/avatar/route.tsx b/app/api/avatar/route.tsx deleted file mode 100644 index 3c4fdee8..00000000 --- a/app/api/avatar/route.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ImageResponse } from "next/og"; -import { NextRequest, NextResponse } from "next/server"; -import { getSettings } from "@/db/services/setting"; - -export const runtime = "nodejs"; - -const AVATAR_COLORS = [ - "#4f46e5", - "#7c3aed", - "#e11d48", - "#ea580c", - "#d97706", - "#059669", - "#0891b2", - "#52525b", -]; - -export async function GET(request: NextRequest) { - const settings = await getSettings(); - if (settings?.avatarMode && settings.avatarMode !== "internal") { - return new NextResponse(null, { status: 404 }); - } - - const { searchParams } = new URL(request.url); - const initials = (searchParams.get("initials") ?? "?") - .slice(0, 2) - .toUpperCase(); - const color = searchParams.get("color") ?? AVATAR_COLORS[0]; - - return new ImageResponse( -
- {initials} -
, - { width: 80, height: 80 }, - ); -} diff --git a/src/features/auth/social-buttons.tsx b/src/features/auth/social-buttons.tsx index 0294a9dd..516a9c54 100644 --- a/src/features/auth/social-buttons.tsx +++ b/src/features/auth/social-buttons.tsx @@ -10,77 +10,95 @@ import { AuthProviderConfig } from "@/lib/auth/config"; import Image from "next/image"; import { useRouter } from "next/navigation"; -export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) { - const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential"); - const router = useRouter(); +export function SocialAuthButtons({ + providers, +}: { + providers: AuthProviderConfig[]; +}) { + const socialProviders = providers.filter( + (p) => p.isActive && p.type !== "credential", + ); + const router = useRouter(); - const [isLoading, setIsLoading] = useState(null); + const [isLoading, setIsLoading] = useState(null); - const handleSocialSignIn = async (provider: AuthProviderConfig) => { - setIsLoading(provider.id); - try { - let result; - if (provider.id === "passkey") { - result = await authClient.signIn.passkey({ - fetchOptions: { - onSuccess() { - router.push("/dashboard"); - }, - onError() { - toast.error("An error occurred during passkey authentication. Please try again."); - }, - }, - }); - } else if (provider.type === "sso") { - result = await authClient.signIn.sso({ - providerId: provider.id, - providerType: "oidc", - callbackURL: "/dashboard", - }); - console.log(result); - } else { - result = await authClient.signIn.social({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - provider: provider.id as any, - callbackURL: "/dashboard", - }); - } + const handleSocialSignIn = async (provider: AuthProviderConfig) => { + setIsLoading(provider.id); + try { + let result; + if (provider.id === "passkey") { + result = await authClient.signIn.passkey({ + fetchOptions: { + onSuccess() { + router.push("/dashboard"); + }, + onError() { + toast.error( + "An error occurred during passkey authentication. Please try again.", + ); + }, + }, + }); + } else if (provider.type === "sso") { + result = await authClient.signIn.sso({ + providerId: provider.id, + providerType: "oidc", + callbackURL: "/dashboard", + }); + } else { + result = await authClient.signIn.social({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: provider.id as any, + callbackURL: "/dashboard", + }); + } - if (result?.error) { - toast.error("An error occurred while signing in with the provider. Please try again."); - } else if (provider.id !== "passkey") { - toast.success("Redirecting to provider..."); - } - } catch (err) { - toast.error("An error occurred while signing in with the provider. Please try again."); - } finally { - setIsLoading(null); - } - }; + if (result?.error) { + toast.error( + "An error occurred while signing in with the provider. Please try again.", + ); + } else if (provider.id !== "passkey") { + toast.success("Redirecting to provider..."); + } + } catch (err) { + toast.error( + "An error occurred while signing in with the provider. Please try again.", + ); + } finally { + setIsLoading(null); + } + }; - if (socialProviders.length === 0) return null; + if (socialProviders.length === 0) return null; - return ( -
- {socialProviders.map((provider) => ( - - ))} -
- ); + return ( +
+ {socialProviders.map((provider) => ( + + ))} +
+ ); } diff --git a/src/features/layout/logged-in-button.tsx b/src/features/layout/logged-in-button.tsx index caeb8a87..b0842bd3 100644 --- a/src/features/layout/logged-in-button.tsx +++ b/src/features/layout/logged-in-button.tsx @@ -37,7 +37,7 @@ export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, >
- + {(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()} {avatarUrl && } diff --git a/src/features/onboarding/components/db-settings/storage-section.tsx b/src/features/onboarding/components/db-settings/storage-section.tsx index daba5420..c9361f22 100644 --- a/src/features/onboarding/components/db-settings/storage-section.tsx +++ b/src/features/onboarding/components/db-settings/storage-section.tsx @@ -27,7 +27,7 @@ export const StorageSection = ({ initial, storages, onSave, isPending }: Storage } noChannelsMessage={

- Go back and configure storages in the "Connect a storage" step first. + Go back and configure storages in the "Connect an external storage" step first.

} /> diff --git a/src/features/onboarding/onboarding-state.ts b/src/features/onboarding/onboarding-state.ts index d0b3e8ab..62ea272e 100644 --- a/src/features/onboarding/onboarding-state.ts +++ b/src/features/onboarding/onboarding-state.ts @@ -107,14 +107,16 @@ export async function resolveOnboardingState(): Promise organizationId: n.organizationId ?? null, })); - const storages = storageChannels.map((s) => ({ - id: s.id, - provider: s.provider, - label: s.provider, - name: s.name, - config: (s.config as Record) ?? {}, - organizationId: s.organizationId ?? null, - })); + const storages = storageChannels + .filter((s) => s.provider !== "local") + .map((s) => ({ + id: s.id, + provider: s.provider, + label: s.provider, + name: s.name, + config: (s.config as Record) ?? {}, + organizationId: s.organizationId ?? null, + })); const defaults = { notifierId: settings?.defaultNotificationChannelId ?? undefined, @@ -168,6 +170,10 @@ export async function resolveOnboardingState(): Promise const hasAgents = agents && agents.length > 0; + const defaultsConfigured = + !!settings?.defaultNotificationChannelId && + !!settings?.defaultStorageChannelId; + if (project) { if (!hasAgents) { if (notifiers.length === 0) { @@ -178,8 +184,12 @@ export async function resolveOnboardingState(): Promise meta.resumeStepId = "storage"; return { stepId: "storage", flowData: fullData }; } - meta.resumeStepId = "agent-create"; - return { stepId: "agent-create", flowData: fullData }; + if (defaultsConfigured) { + meta.resumeStepId = "agent-create"; + return { stepId: "agent-create", flowData: fullData }; + } + meta.resumeStepId = "defaults"; + return { stepId: "defaults", flowData: fullData }; } const agentHasPinged = !!agents[0]?.lastContact; @@ -215,6 +225,11 @@ export async function resolveOnboardingState(): Promise return { stepId: "storage", flowData: fullData }; } - meta.resumeStepId = "agent-create"; - return { stepId: "agent-create", flowData: fullData }; + if (defaultsConfigured) { + meta.resumeStepId = "agent-create"; + return { stepId: "agent-create", flowData: fullData }; + } + + meta.resumeStepId = "defaults"; + return { stepId: "defaults", flowData: fullData }; } diff --git a/src/features/onboarding/onboarding-steps.tsx b/src/features/onboarding/onboarding-steps.tsx index 92b3a47c..5c1993ca 100644 --- a/src/features/onboarding/onboarding-steps.tsx +++ b/src/features/onboarding/onboarding-steps.tsx @@ -105,7 +105,11 @@ export const onboardingSteps: OnboardingStep[] = [ id: "agent-key", component: StepAgentKey, isSkippable: false, - nextStep: "agent-waiting", + nextStep: (ctx: any) => { + const agents = (ctx.flowData?.agents as any[]) || []; + const isConnected = agents.some((a) => a.connected); + return isConnected ? "project-create" : "agent-waiting"; + }, }, { id: "agent-waiting", diff --git a/src/features/onboarding/steps/step-db-settings.tsx b/src/features/onboarding/steps/step-db-settings.tsx index df616609..d57a4e56 100644 --- a/src/features/onboarding/steps/step-db-settings.tsx +++ b/src/features/onboarding/steps/step-db-settings.tsx @@ -100,7 +100,9 @@ export const StepDbSettings = () => { storagePolicies: settings.storagePolicies, }); succeededIds.push(targetId); - } catch {} + } catch (err) { + console.error("Failed to apply settings to database", targetId, err); + } } if (succeededIds.length > 0) { diff --git a/src/features/onboarding/steps/step-defaults.tsx b/src/features/onboarding/steps/step-defaults.tsx index 4be58bb1..721d5abd 100644 --- a/src/features/onboarding/steps/step-defaults.tsx +++ b/src/features/onboarding/steps/step-defaults.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { @@ -46,61 +47,85 @@ export const StepDefaults = () => { ); const selectNotifier = async (value: string) => { + const prev = notifierId; setNotifierId(value); - await updateNotificationSettingsAction({ - name: "system", - data: { notificationChannelId: value }, - }); - await updateContext({ - flowData: { - ...state?.context.flowData, - defaults: { notifierId: value, storageId, avatarMode }, - }, - }); + try { + await updateNotificationSettingsAction({ + name: "system", + data: { notificationChannelId: value }, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId: value, storageId, avatarMode, dicebearStyle }, + }, + }); + } catch { + setNotifierId(prev); + toast.error("Failed to save default notifier"); + } }; const selectStorage = async (value: string) => { + const prev = storageId; setStorageId(value); - await updateStorageSettingsAction({ - name: "system", - data: { storageChannelId: value, encryption: false }, - }); - await updateContext({ - flowData: { - ...state?.context.flowData, - defaults: { notifierId, storageId: value, avatarMode }, - }, - }); + try { + await updateStorageSettingsAction({ + name: "system", + data: { storageChannelId: value, encryption: false }, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId: value, avatarMode, dicebearStyle }, + }, + }); + } catch { + setStorageId(prev); + toast.error("Failed to save default storage"); + } }; const selectAvatarMode = async (mode: AvatarMode) => { + const prev = avatarMode; setAvatarMode(mode); - await updateAvatarModeAction({ - name: "system", - avatarMode: mode, - dicebearStyle, - }); - await updateContext({ - flowData: { - ...state?.context.flowData, - defaults: { notifierId, storageId, avatarMode: mode, dicebearStyle }, - }, - }); + try { + await updateAvatarModeAction({ + name: "system", + avatarMode: mode, + dicebearStyle, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId, avatarMode: mode, dicebearStyle }, + }, + }); + } catch { + setAvatarMode(prev); + toast.error("Failed to save avatar mode"); + } }; const selectDicebearStyle = async (style: string) => { + const prev = dicebearStyle; setDicebearStyle(style); - await updateAvatarModeAction({ - name: "system", - avatarMode: "dicebear", - dicebearStyle: style, - }); - await updateContext({ - flowData: { - ...state?.context.flowData, - defaults: { notifierId, storageId, avatarMode, dicebearStyle: style }, - }, - }); + try { + await updateAvatarModeAction({ + name: "system", + avatarMode: "dicebear", + dicebearStyle: style, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId, avatarMode, dicebearStyle: style }, + }, + }); + } catch { + setDicebearStyle(prev); + toast.error("Failed to save avatar style"); + } }; const onContinue = async () => { @@ -218,7 +243,7 @@ export const StepDefaults = () => { {avatarMode === "dicebear" && ( - + )}
-
-

Avatar

-
- {AVATAR_COLORS.map((c) => { - const url = `/api/avatar?initials=${initials}&color=${encodeURIComponent(c.hex)}`; - const isSelected = selectedAvatarUrl === url; - return ( - - ); - })} -
-
- diff --git a/src/features/onboarding/steps/step-storage.tsx b/src/features/onboarding/steps/step-storage.tsx index 190c97e6..21523149 100644 --- a/src/features/onboarding/steps/step-storage.tsx +++ b/src/features/onboarding/steps/step-storage.tsx @@ -135,9 +135,9 @@ export const StepStorage = () => { return (
-

Connect a storage

+

Connect an external storage

- Optional — choose where backups and files are stored. + Optional — link an S3-compatible bucket or other external storage to keep your backups safe and off-instance.

diff --git a/src/features/onboarding/types/index.ts b/src/features/onboarding/types/index.ts index b00761af..58c75551 100644 --- a/src/features/onboarding/types/index.ts +++ b/src/features/onboarding/types/index.ts @@ -23,7 +23,6 @@ export type OnboardingSecurityData = { export type OnboardingPreferencesData = { theme: "light" | "dark"; - avatarUrl?: string; }; export type OnboardingOrgData = { diff --git a/src/features/profile/avatar-with-upload.tsx b/src/features/profile/avatar-with-upload.tsx index 12dfd898..72780844 100644 --- a/src/features/profile/avatar-with-upload.tsx +++ b/src/features/profile/avatar-with-upload.tsx @@ -1,100 +1,101 @@ "use client"; -import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; -import {UploadIcon} from "lucide-react"; -import {toast} from "sonner"; -import {uploadUserImageAction} from "@/features/upload/upload.action"; -import {useMutation} from "@tanstack/react-query"; -import {updateImageUserAction} from "@/features/profile/avatar.action"; -import {useRouter} from "next/navigation"; -import {User} from "@/db/schema/02_user"; -import React, {ChangeEvent} from "react"; -import type {AvatarMode} from "@/features/onboarding/types"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { UploadIcon } from "lucide-react"; +import { toast } from "sonner"; +import { uploadUserImageAction } from "@/features/upload/upload.action"; +import { useMutation } from "@tanstack/react-query"; +import { updateImageUserAction } from "@/features/profile/avatar.action"; +import { useRouter } from "next/navigation"; +import { User } from "@/db/schema/02_user"; +import React, { ChangeEvent } from "react"; +import type { AvatarMode } from "@/features/onboarding/types"; export type AvatarWithUploadProps = { - user: User; - avatarMode?: AvatarMode; - avatarUrl?: string; + user: User; + avatarMode?: AvatarMode; + avatarUrl?: string; }; export const AvatarWithUpload = (props: AvatarWithUploadProps) => { - const user = props.user; - const canUpload = !props.avatarMode || props.avatarMode === "internal"; - const src = props.avatarUrl ?? user.image ?? undefined; - const router = useRouter(); + const user = props.user; + const canUpload = !props.avatarMode || props.avatarMode === "internal"; + const src = props.avatarUrl ?? user.image ?? undefined; + const router = useRouter(); - const submitImage = useMutation({ - mutationFn: async (file: File) => { - const formData = new FormData(); - formData.set("file", file); - const result = await uploadUserImageAction(formData); + const submitImage = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.set("file", file); + const result = await uploadUserImageAction(formData); - const inner = result?.data; + const inner = result?.data; - if (inner?.success) { + if (inner?.success) { + const updateUser = await updateImageUserAction(inner.value ?? ""); + const dataUser = updateUser?.data; - const updateUser = await updateImageUserAction(inner.value ?? ""); - const dataUser = updateUser?.data; - - if (updateUser?.serverError || !dataUser) { - toast.error(updateUser?.serverError); - return; - } - - toast.success(inner.actionSuccess?.message); - router.refresh(); - } else { - toast.error(inner?.actionError?.message); - } - - - }, - }); - - const handleImageUpload = async (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const allowedFormats = ["image/jpeg", "image/png", "image/webp"]; - - if (!allowedFormats.includes(file.type)) { - toast.error("Only JPG, PNG or WebP images are allowed."); - return; + if (updateUser?.serverError || !dataUser) { + toast.error(updateUser?.serverError); + return; } - const maxSizeInMB = 5; - if (file.size > maxSizeInMB * 1024 * 1024) { - toast.error(`Image must be smaller than ${maxSizeInMB}MB.`); - return; - } + toast.success(inner.actionSuccess?.message); + router.refresh(); + } else { + toast.error(inner?.actionError?.message); + } + }, + }); - submitImage.mutate(file); - }; + const handleImageUpload = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const allowedFormats = ["image/jpeg", "image/png", "image/webp"]; + if (!allowedFormats.includes(file.type)) { + toast.error("Only JPG, PNG or WebP images are allowed."); + return; + } - return ( -
+ const maxSizeInMB = 5; + if (file.size > maxSizeInMB * 1024 * 1024) { + toast.error(`Image must be smaller than ${maxSizeInMB}MB.`); + return; + } - - - {(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()} - + submitImage.mutate(file); + }; - {canUpload && ( -
{ - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".jpg,.jpeg,.png,.webp"; - fileInput.onchange = (e: Event) => - handleImageUpload(e as unknown as React.ChangeEvent); - fileInput.click(); - }} - className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32" - > - -
- )} + return ( +
+ + {src && } + + {(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()} + + + + {canUpload && ( +
{ + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".jpg,.jpeg,.png,.webp"; + fileInput.onchange = (e: Event) => + handleImageUpload( + e as unknown as React.ChangeEvent, + ); + fileInput.click(); + }} + className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32" + > +
- ); + )} +
+ ); }; diff --git a/src/features/settings/avatar-mode-selector.tsx b/src/features/settings/avatar-mode-selector.tsx index 3d7c9f1e..17cbd8fc 100644 --- a/src/features/settings/avatar-mode-selector.tsx +++ b/src/features/settings/avatar-mode-selector.tsx @@ -28,9 +28,9 @@ export const AvatarModeSelector = ({ value, onChange, disabled }: Props) => ( key={mode.value} type="button" disabled={disabled} - onClick={() => onChange(mode.value)} + onClick={() => { if (!isActive) onChange(mode.value); }} className={cn( - "flex flex-col gap-2 rounded-xl border-2 p-3 text-left transition-all hover:bg-accent/50 disabled:opacity-50", + "flex flex-col gap-3 rounded-xl border-2 p-4 text-left transition-all hover:bg-accent/50 disabled:opacity-50", isActive ? "border-primary bg-primary/5" : "border-muted/40", )} > diff --git a/src/features/settings/avatar-section.tsx b/src/features/settings/avatar-section.tsx index fa6d641f..7e74c940 100644 --- a/src/features/settings/avatar-section.tsx +++ b/src/features/settings/avatar-section.tsx @@ -42,7 +42,7 @@ export const SettingsAvatarSection = ({ settings }: Props) => { }; return ( -
+

Avatar

diff --git a/src/features/settings/dicebear-style-picker.tsx b/src/features/settings/dicebear-style-picker.tsx index c9b85eb4..78d7ec88 100644 --- a/src/features/settings/dicebear-style-picker.tsx +++ b/src/features/settings/dicebear-style-picker.tsx @@ -10,9 +10,10 @@ type Props = { value: string; onChange: (style: string) => void; disabled?: boolean; + maxHeight?: string; }; -export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => { +export const DicebearStylePicker = ({ value, onChange, disabled, maxHeight = "max-h-96" }: Props) => { const dicebearState = useDiceBearStyles(); return ( @@ -43,7 +44,7 @@ export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => { )} {dicebearState.status === "success" && ( -

+
{dicebearState.styles.map((style) => { const isActive = value === style; @@ -52,7 +53,7 @@ export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => { key={style} type="button" disabled={disabled} - onClick={() => onChange(style)} + onClick={() => { if (!isActive) onChange(style); }} title={style} className={cn( "flex flex-col items-center gap-1 rounded-lg border-2 p-1.5 transition-all hover:bg-accent/50 disabled:opacity-50", diff --git a/src/features/settings/settings-tabs.tsx b/src/features/settings/settings-tabs.tsx index 3c2f5de5..8eba81b1 100644 --- a/src/features/settings/settings-tabs.tsx +++ b/src/features/settings/settings-tabs.tsx @@ -1,33 +1,29 @@ "use client"; -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; -import {useEffect, useState} from "react"; -import {useRouter, useSearchParams} from "next/navigation"; -import {Setting} from "@/db/schema/01_setting"; -import {SettingsEmailSection} from "@/features/settings/email-section"; -import {SettingsStorageSection} from "@/features/settings/storage-section"; -import {StorageChannelWith} from "@/db/schema/12_storage-channel"; -import {AlarmClock, MailboxIcon, Save, UserCircle} from "lucide-react"; -import { - SettingsNotificationSection -} from "@/features/settings/notification-section"; -import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; -import {SettingsAvatarSection} from "@/features/settings/avatar-section"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Setting } from "@/db/schema/01_setting"; +import { SettingsEmailSection } from "@/features/settings/email-section"; +import { SettingsStorageSection } from "@/features/settings/storage-section"; +import { StorageChannelWith } from "@/db/schema/12_storage-channel"; +import { AlarmClock, MailboxIcon, Save, UserCircle } from "lucide-react"; +import { SettingsNotificationSection } from "@/features/settings/notification-section"; +import { NotificationChannelWith } from "@/db/schema/09_notification-channel"; +import { SettingsAvatarSection } from "@/features/settings/avatar-section"; export type SettingsTabsProps = { - settings: Setting - storageChannels: StorageChannelWith[], + settings: Setting; + storageChannels: StorageChannelWith[]; notificationChannels: NotificationChannelWith[]; - }; -export const SettingsTabs = ({settings, storageChannels, notificationChannels}: SettingsTabsProps) => { +export const SettingsTabs = ({ settings, storageChannels, notificationChannels }: SettingsTabsProps) => { const router = useRouter(); const searchParams = useSearchParams(); const [tab, setTab] = useState(() => searchParams.get("tab") ?? "email"); - useEffect(() => { const newTab = searchParams.get("tab") ?? "email"; setTab(newTab); @@ -37,65 +33,51 @@ export const SettingsTabs = ({settings, storageChannels, notificationChannels}: router.push(`?tab=${value}`); }; - const tabs = [ { - name: 'System Email', - value: 'email', + name: "System Email", + value: "email", icon: MailboxIcon, - content: ( - - ) + content: , }, { - name: 'Storage', - value: 'storage', + name: "Storage", + value: "storage", icon: Save, - content: ( - - - ) + content: , }, { - name: 'Notification', - value: 'notification', + name: "Notification", + value: "notification", icon: AlarmClock, - content: ( - - - ) + content: , }, { - name: 'Avatar', - value: 'avatar', + name: "Avatar", + value: "avatar", icon: UserCircle, - content: ( - - ) - } - ] - + content: , + }, + ]; return (
- - {tabs.map(({icon: Icon, name, value}) => ( - - + + {tabs.map(({ icon: Icon, name, value }) => ( + + {name} ))} - {tabs.map(tab => ( - - {tab.content} + {tabs.map((t) => ( + + {t.content} ))}
- - ); }; diff --git a/src/lib/github-releases.ts b/src/lib/github-releases.ts index cb5dea2a..2073d10a 100644 --- a/src/lib/github-releases.ts +++ b/src/lib/github-releases.ts @@ -1,108 +1,162 @@ export interface GitHubRelease { - tag_name: string; - html_url: string; - prerelease: boolean; - name: string; - body: string; + tag_name: string; + html_url: string; + prerelease: boolean; + name: string; + body: string; } type ParsedVersion = { - major: number; - minor: number; - patch: number; - rc?: number; + major: number; + minor: number; + patch: number; + rc?: number; }; function parseVersion(version: string): ParsedVersion { - const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/); - if (!match) return { - major: 0, - minor: 0, - patch: 0, - rc: undefined, + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/); + if (!match) + return { + major: 0, + minor: 0, + patch: 0, + rc: undefined, }; - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - rc: match[4] ? Number(match[4]) : undefined, - }; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + rc: match[4] ? Number(match[4]) : undefined, + }; } -const findLatestVersion = (currentVersion: string, releases: GitHubRelease[]): GitHubRelease | null => { - const cleanCurrentVersion = currentVersion.replace(/^v/, ""); - const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion); - const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion); +const findLatestVersion = ( + currentVersion: string, + releases: GitHubRelease[], +): GitHubRelease | null => { + const cleanCurrentVersion = currentVersion.replace(/^v/, ""); + const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion); + const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion); - const currentParsedVersion = parseVersion(cleanCurrentVersion); + const currentParsedVersion = parseVersion(cleanCurrentVersion); - if (isRc) { - const latestStableVersion = releases.find(r => !r.prerelease); - const latestRcVersion = releases.find(r => r.prerelease && (r.tag_name.includes("rc") || (r.name && r.name.includes("rc")))); + if (isRc) { + const latestStableVersion = releases.find((r) => !r.prerelease); + const latestRcVersion = releases.find( + (r) => + r.prerelease && + (r.tag_name.includes("rc") || (r.name && r.name.includes("rc"))), + ); - if (latestStableVersion) { - const versionStr = latestStableVersion.tag_name || latestStableVersion.name; - const latestStableParsedVersion = parseVersion(versionStr); - if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch >= currentParsedVersion.patch) return latestStableVersion; - } - - if (latestRcVersion) { - const versionStr = latestRcVersion.tag_name || latestRcVersion.name; - const latestRcParsedVersion = parseVersion(versionStr); - if (latestRcParsedVersion.major > currentParsedVersion.major) return latestRcVersion; - if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor > currentParsedVersion.minor) return latestRcVersion; - if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch > currentParsedVersion.patch) return latestRcVersion; - if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch === currentParsedVersion.patch && - latestRcParsedVersion.rc !== undefined && currentParsedVersion.rc !== undefined && latestRcParsedVersion.rc > currentParsedVersion.rc) return latestRcVersion; - } - } else if (isStable) { - const latestStableVersion = releases.find(r => !r.prerelease); - if (latestStableVersion) { - const versionStr = latestStableVersion.tag_name || latestStableVersion.name; - const latestStableParsedVersion = parseVersion(versionStr); - - if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion; - if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch > currentParsedVersion.patch) return latestStableVersion; - } + if (latestStableVersion) { + const versionStr = + latestStableVersion.tag_name || latestStableVersion.name; + const latestStableParsedVersion = parseVersion(versionStr); + if (latestStableParsedVersion.major > currentParsedVersion.major) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor > currentParsedVersion.minor + ) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor === currentParsedVersion.minor && + latestStableParsedVersion.patch >= currentParsedVersion.patch + ) + return latestStableVersion; } + if (latestRcVersion) { + const versionStr = latestRcVersion.tag_name || latestRcVersion.name; + const latestRcParsedVersion = parseVersion(versionStr); + if (latestRcParsedVersion.major > currentParsedVersion.major) + return latestRcVersion; + if ( + latestRcParsedVersion.major === currentParsedVersion.major && + latestRcParsedVersion.minor > currentParsedVersion.minor + ) + return latestRcVersion; + if ( + latestRcParsedVersion.major === currentParsedVersion.major && + latestRcParsedVersion.minor === currentParsedVersion.minor && + latestRcParsedVersion.patch > currentParsedVersion.patch + ) + return latestRcVersion; + if ( + latestRcParsedVersion.major === currentParsedVersion.major && + latestRcParsedVersion.minor === currentParsedVersion.minor && + latestRcParsedVersion.patch === currentParsedVersion.patch && + latestRcParsedVersion.rc !== undefined && + currentParsedVersion.rc !== undefined && + latestRcParsedVersion.rc > currentParsedVersion.rc + ) + return latestRcVersion; + } + } else if (isStable) { + const latestStableVersion = releases.find((r) => !r.prerelease); + if (latestStableVersion) { + const versionStr = + latestStableVersion.tag_name || latestStableVersion.name; + const latestStableParsedVersion = parseVersion(versionStr); + + if (latestStableParsedVersion.major > currentParsedVersion.major) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor > currentParsedVersion.minor + ) + return latestStableVersion; + if ( + latestStableParsedVersion.major === currentParsedVersion.major && + latestStableParsedVersion.minor === currentParsedVersion.minor && + latestStableParsedVersion.patch > currentParsedVersion.patch + ) + return latestStableVersion; + } + } + + return null; +}; + +export const getNewRelease = async ( + currentVersion: string, +): Promise => { + try { + const response = await fetch( + "https://api.github.com/repos/Portabase/portabase/releases", + ); + if (!response.ok) { + return null; + } + + const releases: GitHubRelease[] = await response.json(); + return findLatestVersion(currentVersion, releases); + } catch (error) { + console.error("Failed to fetch latest release", error); return null; + } }; -export const getNewRelease = async (currentVersion: string): Promise => { - try { - const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases"); - if (!response.ok) { - return null; - } - - const releases: GitHubRelease[] = await response.json(); - return findLatestVersion(currentVersion, releases); - - } catch (error) { - console.error("Failed to fetch latest release", error); - return null; - } -}; - -export const getNewAgentRelease = async (currentVersion: string): Promise => { - try { - const response = await fetch("https://api.github.com/repos/Portabase/agent-rust/releases", { - next: { revalidate: 3600 } - }); - if (!response.ok) { - return null; - } - - const releases: GitHubRelease[] = await response.json(); - return findLatestVersion(currentVersion, releases); - - } catch (error) { - console.error("Failed to fetch latest agent release", error); - return null; +export const getNewAgentRelease = async ( + currentVersion: string, +): Promise => { + try { + const response = await fetch( + "https://api.github.com/repos/Portabase/agent-rust/releases", + { + next: { revalidate: 3600 }, + }, + ); + if (!response.ok) { + return null; } + + const releases: GitHubRelease[] = await response.json(); + return findLatestVersion(currentVersion, releases); + } catch (error) { + console.error("Failed to fetch latest agent release", error); + return null; + } }; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 9337e144..d788af3e 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -5,43 +5,45 @@ const defaultLevel = isProd ? "info" : "debug"; const level = (process.env.LOG_LEVEL ?? defaultLevel) as pino.Level; function getLocalTimestamp() { - const date = new Date(); + const date = new Date(); - const formatted = new Intl.DateTimeFormat("en-US", { - timeZone: process.env.TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }).format(date).replace(" ", "T"); + const formatted = new Intl.DateTimeFormat("en-US", { + timeZone: process.env.TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + .format(date) + .replace(" ", "T"); - return `,"time":"${formatted}"`; + return `,"time":"${formatted}"`; } export const logger: Logger = pino({ - level: level, - base: null, - ...(isProd - ? { - timestamp: getLocalTimestamp, - formatters: { - level(label) { - return { level: label.toUpperCase() }; - } - } - } - : { - timestamp: getLocalTimestamp, - transport: { - target: "pino-pretty", - options: { - colorize: true, - translateTime: "yyyy-mm-dd HH:MM:ss", - ignore: "pid,hostname", - levelFirst: true - } - } - }) + level: level, + base: null, + ...(isProd + ? { + timestamp: getLocalTimestamp, + formatters: { + level(label) { + return { level: label.toUpperCase() }; + }, + }, + } + : { + timestamp: getLocalTimestamp, + transport: { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "yyyy-mm-dd HH:MM:ss", + ignore: "pid,hostname", + levelFirst: true, + }, + }, + }), }); diff --git a/src/lib/tasks/cleaning/index.ts b/src/lib/tasks/cleaning/index.ts index c584d018..119fbe81 100644 --- a/src/lib/tasks/cleaning/index.ts +++ b/src/lib/tasks/cleaning/index.ts @@ -1,30 +1,33 @@ -import {db} from "@/db"; -import {and, eq, isNotNull, isNull} from "drizzle-orm"; +import { db } from "@/db"; +import { and, eq, isNotNull } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {withUpdatedAt} from "@/db/utils"; -import {logger} from "@/lib/logger"; +import { withUpdatedAt } from "@/db/utils"; +import { logger } from "@/lib/logger"; -const log = logger.child({module: "tasks/cleaning"}); +const log = logger.child({ module: "tasks/cleaning" }); export const backupCleanTask = async () => { - try { - const backups = await db.query.backup.findMany({ - where: and( - isNotNull(drizzleDb.schemas.backup.deletedAt), - eq(drizzleDb.schemas.backup.status, "ongoing") - ) - }); - log.debug(`Backups to clean: ${backups.length}`); + try { + const backups = await db.query.backup.findMany({ + where: and( + isNotNull(drizzleDb.schemas.backup.deletedAt), + eq(drizzleDb.schemas.backup.status, "ongoing"), + ), + }); + log.debug(`Backups to clean: ${backups.length}`); - for (const backup of backups) { - await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({ - status: "failed", - })) - .where(eq(drizzleDb.schemas.backup.id, backup.id)); - } - - } catch (e: any) { - log.error({name: "backupCleanTask", error: e},`Backup cleanup failed`); - throw e; + for (const backup of backups) { + await db + .update(drizzleDb.schemas.backup) + .set( + withUpdatedAt({ + status: "failed", + }), + ) + .where(eq(drizzleDb.schemas.backup.id, backup.id)); } -}; \ No newline at end of file + } catch (e: any) { + log.error({ name: "backupCleanTask", error: e }, `Backup cleanup failed`); + throw e; + } +}; diff --git a/src/lib/tasks/database/index.ts b/src/lib/tasks/database/index.ts index 337bf469..69a24435 100644 --- a/src/lib/tasks/database/index.ts +++ b/src/lib/tasks/database/index.ts @@ -1,58 +1,57 @@ -import {db} from "@/db"; -import {enforceRetentionCount} from "@/lib/tasks/database/retention-count"; -import {enforceRetentionDays} from "@/lib/tasks/database/retention-days"; -import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf"; -import {retentionPolicy} from "@/db/schema/07_database"; -import {isNull} from "drizzle-orm"; +import { db } from "@/db"; +import { enforceRetentionCount } from "@/lib/tasks/database/retention-count"; +import { enforceRetentionDays } from "@/lib/tasks/database/retention-days"; +import { enforceRetentionGFS } from "@/lib/tasks/database/retention-gsf"; +import { retentionPolicy } from "@/db/schema/07_database"; +import { isNull } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {logger} from "@/lib/logger"; +import { logger } from "@/lib/logger"; -const log = logger.child({module: "tasks/database"}); +const log = logger.child({ module: "tasks/database" }); export const retentionCleanTask = async () => { - try { - const databases = await db.query.database.findMany({ - with: { - retentionPolicy: true, - backups: { - where: isNull(drizzleDb.schemas.backup.deletedAt), - }, - }, - }); - log.info(`Retention databases number: ${databases.length}`); - for (const db of databases) { - if (!db.retentionPolicy) continue; - await enforceRetention(db.id, db.retentionPolicy); - } - } catch (e: any) { - log.error({error: e},"Retention cleanup failed"); - throw e; + try { + const databases = await db.query.database.findMany({ + with: { + retentionPolicy: true, + backups: { + where: isNull(drizzleDb.schemas.backup.deletedAt), + }, + }, + }); + log.info(`Retention databases number: ${databases.length}`); + for (const db of databases) { + if (!db.retentionPolicy) continue; + await enforceRetention(db.id, db.retentionPolicy); } + } catch (e: any) { + log.error({ error: e }, "Retention cleanup failed"); + throw e; + } }; export async function enforceRetention( - databaseId: string, - policy: typeof retentionPolicy.$inferSelect + databaseId: string, + policy: typeof retentionPolicy.$inferSelect, ) { -; - log.info({name: "enforceRetention"},`Retention started for ${databaseId}`); + log.info({ name: "enforceRetention" }, `Retention started for ${databaseId}`); - switch (policy.type) { - case "count": - await enforceRetentionCount(databaseId, policy.count ?? 7); - break; + switch (policy.type) { + case "count": + await enforceRetentionCount(databaseId, policy.count ?? 7); + break; - case "days": - await enforceRetentionDays(databaseId, policy.days ?? 30); - break; + case "days": + await enforceRetentionDays(databaseId, policy.days ?? 30); + break; - case "gfs": - await enforceRetentionGFS(databaseId, { - daily: policy.gfsDaily ?? 7, - weekly: policy.gfsWeekly ?? 4, - monthly: policy.gfsMonthly ?? 12, - yearly: policy.gfsYearly ?? 3, - }); - break; - } + case "gfs": + await enforceRetentionGFS(databaseId, { + daily: policy.gfsDaily ?? 7, + weekly: policy.gfsWeekly ?? 4, + monthly: policy.gfsMonthly ?? 12, + yearly: policy.gfsYearly ?? 3, + }); + break; + } } diff --git a/src/lib/tasks/database/utils/delete.ts b/src/lib/tasks/database/utils/delete.ts index ce518243..27b99eec 100644 --- a/src/lib/tasks/database/utils/delete.ts +++ b/src/lib/tasks/database/utils/delete.ts @@ -1,97 +1,104 @@ -"use server" -import {action} from "@/lib/safe-actions/actions"; -import {z} from "zod"; -import {ServerActionResult} from "@/types/action-type"; -import {Backup} from "@/db/schema/07_database"; -import {db} from "@/db"; +"use server"; +import { action } from "@/lib/safe-actions/actions"; +import { z } from "zod"; +import { ServerActionResult } from "@/types/action-type"; +import { Backup } from "@/db/schema/07_database"; +import { db } from "@/db"; import * as drizzleDb from "@/db"; -import {and, eq} from "drizzle-orm"; -import {withUpdatedAt} from "@/db/utils"; -import type {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; - +import { and, eq } from "drizzle-orm"; +import { withUpdatedAt } from "@/db/utils"; +import type { StorageInput } from "@/features/storages/storages.types"; +import { dispatchStorage } from "@/features/storages/storages.dispatch"; export const deleteBackupCronAction = action - .schema( - z.object({ - backupId: z.string(), - databaseId: z.string(), - }) - ) - .action(async ({parsedInput}): Promise> => { - try { + .schema( + z.object({ + backupId: z.string(), + databaseId: z.string(), + }), + ) + .action(async ({ parsedInput }): Promise> => { + try { + const backup = await db.query.backup.findFirst({ + where: and( + eq(drizzleDb.schemas.backup.id, parsedInput.backupId), + eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId), + ), + }); - const backup = await db.query.backup.findFirst({ - where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)) - }); + if (!backup) { + return { + success: false, + actionError: { + message: "Backup not found.", + status: 404, + messageParams: { backupId: parsedInput.backupId }, + }, + }; + } + const backupStorages = await db.query.backupStorage.findMany({ + where: eq( + drizzleDb.schemas.backupStorage.backupId, + parsedInput.backupId, + ), + }); - if (!backup) { - return { - success: false, - actionError: { - message: "Backup not found.", - status: 404, - messageParams: {backupId: parsedInput.backupId}, - }, - } - } + for (const backupStorage of backupStorages) { + await db + .update(drizzleDb.schemas.backupStorage) + .set( + withUpdatedAt({ + deletedAt: new Date(), + }), + ) + .where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id)); - - const backupStorages = await db.query.backupStorage.findMany({ - where: eq(drizzleDb.schemas.backupStorage.backupId, parsedInput.backupId), - }); - - - for (const backupStorage of backupStorages) { - - await db - .update(drizzleDb.schemas.backupStorage) - .set(withUpdatedAt({ - deletedAt: new Date(), - })) - .where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id)) - - if (backupStorage.status != "success" || !backupStorage.path) { - continue; - } - - const input: StorageInput = { - action: "delete", - data: { - path: backupStorage.path, - }, - }; - - await dispatchStorage(input, undefined, backupStorage.storageChannelId); - } - - await db - .update(drizzleDb.schemas.backup) - .set(withUpdatedAt({ - deletedAt: new Date(), - status: backup.status == "ongoing" ? "failed" : backup.status - - })) - .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) - - return { - success: true, - actionSuccess: { - message: `Backup deleted successfully (${parsedInput.backupId}).`, - }, - }; - - } catch (error) { - console.error(error); - return { - success: false, - actionError: { - message: `Failed to delete backup(${parsedInput.backupId}).`, - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {message: "Error deleting the backup"}, - }, - }; + if (backupStorage.status != "success" || !backupStorage.path) { + continue; } - }); \ No newline at end of file + + const input: StorageInput = { + action: "delete", + data: { + path: backupStorage.path, + }, + }; + + await dispatchStorage(input, undefined, backupStorage.storageChannelId); + } + + await db + .update(drizzleDb.schemas.backup) + .set( + withUpdatedAt({ + deletedAt: new Date(), + status: backup.status == "ongoing" ? "failed" : backup.status, + }), + ) + .where( + and( + eq(drizzleDb.schemas.backup.id, parsedInput.backupId), + eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId), + ), + ); + + return { + success: true, + actionSuccess: { + message: `Backup deleted successfully (${parsedInput.backupId}).`, + }, + }; + } catch (error) { + console.error(error); + return { + success: false, + actionError: { + message: `Failed to delete backup(${parsedInput.backupId}).`, + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: { message: "Error deleting the backup" }, + }, + }; + } + }); diff --git a/src/lib/twx.tsx b/src/lib/twx.tsx index 721579d1..ee4a3fb3 100644 --- a/src/lib/twx.tsx +++ b/src/lib/twx.tsx @@ -1,4 +1,4 @@ import { createTwc } from "react-twc"; import { cn } from "./utils"; -export const twx = createTwc({ compose: cn}); +export const twx = createTwc({ compose: cn }); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391d..a5ef1935 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 227b22fb..e59be2da 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,8 +1,11 @@ -import {NextResponse} from "next/server"; +import { NextResponse } from "next/server"; export const errorHandler = (error: any) => { - return new NextResponse( - JSON.stringify({ message: 'An error occurred while processing your request.', status: 500 }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); -}; \ No newline at end of file + return new NextResponse( + JSON.stringify({ + message: "An error occurred while processing your request.", + status: 500, + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); +}; diff --git a/src/middleware/loggingMiddleware.ts b/src/middleware/loggingMiddleware.ts index 532bfe6d..9d209928 100644 --- a/src/middleware/loggingMiddleware.ts +++ b/src/middleware/loggingMiddleware.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { logger } from "@/lib/logger"; +import { NextRequest, NextResponse } from "next/server"; const LOG_LEVELS = ["debug", "info", "warn", "error"] as const; -type LogLevel = typeof LOG_LEVELS[number]; +type LogLevel = (typeof LOG_LEVELS)[number]; const raw = (process.env.LOG_LEVEL ?? "info") as LogLevel; const _idx = LOG_LEVELS.indexOf(raw); @@ -10,13 +11,14 @@ const LEVEL_INDEX = _idx === -1 ? 1 : _idx; const STATUS_PATH_RE = /\/api\/agent\/[^/]+\/status\/?$/; export function loggingMiddleware(request: NextRequest) { - if (request.url.includes('/api')) { - const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname); - // Status pings are debug-level; all other API calls are info-level. - const requiredIndex = isStatusPing ? 0 : 1; - if (LEVEL_INDEX <= requiredIndex) { - console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`); - } + if (request.url.includes("/api")) { + const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname); + const requiredIndex = isStatusPing ? 0 : 1; + if (LEVEL_INDEX <= requiredIndex) { + logger.info( + `[API] Received ${request.method} request : ${request.url} at ${new Date()}`, + ); } - return NextResponse.next(); -} \ No newline at end of file + } + return NextResponse.next(); +} diff --git a/src/types/action-type.ts b/src/types/action-type.ts index ac18c0e3..f0d6b143 100644 --- a/src/types/action-type.ts +++ b/src/types/action-type.ts @@ -2,30 +2,33 @@ import { SafeActionResult } from "next-safe-action"; import { ZodString } from "zod"; export type ServerActionResult = - | { success: true; value?: T; actionSuccess?: ActionSuccessMessage } - | { - success: false; - actionError?: ActionErrorMessage; - }; + | { success: true; value?: T; actionSuccess?: ActionSuccessMessage } + | { + success: false; + actionError?: ActionErrorMessage; + }; export type ActionErrorMessage = { - message?: string; - status?: number; - cause?: string; - messageParams?: Record; + message?: string; + status?: number; + cause?: string; + messageParams?: Record; }; -export type ActionSuccessMessage = { message?: string; messageParams?: Record }; +export type ActionSuccessMessage = { + message?: string; + messageParams?: Record; +}; export type ActionResult = SafeActionResult< - string, - ZodString, - readonly [], - { - formErrors: string[]; - fieldErrors: {}; - }, - readonly [], - ServerActionResult, - object + string, + ZodString, + readonly [], + { + formErrors: string[]; + fieldErrors: {}; + }, + readonly [], + ServerActionResult, + object >; diff --git a/src/types/auth.ts b/src/types/auth.ts index d3727eef..bf2cba27 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,17 +1,16 @@ export type SignUpUser = { - name: string - email: string - password: string - callbackURL?: string - role?: string - theme: string - emailVerified?: boolean -} - + name: string; + email: string; + password: string; + callbackURL?: string; + role?: string; + theme: string; + emailVerified?: boolean; +}; export type BetterAuthError = { - code?: string; - message?: string; - status: number; - statusText: string; -}; \ No newline at end of file + code?: string; + message?: string; + status: number; + statusText: string; +}; diff --git a/src/types/common.ts b/src/types/common.ts index 8318e502..290a441e 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,2 +1,2 @@ export type MemberRole = "member" | "admin" | "owner"; -export type MemberRoleType = MemberRole | MemberRole[]; \ No newline at end of file +export type MemberRoleType = MemberRole | MemberRole[]; diff --git a/src/types/next.ts b/src/types/next.ts index 08e428b9..d1db265b 100644 --- a/src/types/next.ts +++ b/src/types/next.ts @@ -1,9 +1,9 @@ export type LayoutParams> = { - children: React.ReactNode; - params: T; + children: React.ReactNode; + params: T; }; export type PageParams> = { - params: Promise; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + params: Promise; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; diff --git a/src/utils/common.ts b/src/utils/common.ts index 1fefdead..ee1f4b8d 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,89 +1,91 @@ -import {MemberWithUser, Organization, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; -import {OrganizationMember} from "@/db/schema/04_member"; -import {OrganizationInvitation} from "@/db/schema/05_invitation"; -import {User} from "@/db/schema/02_user"; - +import { + MemberWithUser, + Organization, + OrganizationWithMembersAndUsers, +} from "@/db/schema/03_organization"; +import { OrganizationMember } from "@/db/schema/04_member"; +import { OrganizationInvitation } from "@/db/schema/05_invitation"; +import { User } from "@/db/schema/02_user"; export function buildOrganizationWithMembers( - rows: { - organization: Organization; - member: OrganizationMember | null; - invitation: OrganizationInvitation | null; - user: User | null; - }[] + rows: { + organization: Organization; + member: OrganizationMember | null; + invitation: OrganizationInvitation | null; + user: User | null; + }[], ): OrganizationWithMembersAndUsers | null { - if (rows.length === 0) return null; + if (rows.length === 0) return null; - const org = rows[0].organization; + const org = rows[0].organization; + const invitations: OrganizationInvitation[] = rows + .filter((r) => r.invitation) + .map((r) => ({ + ...r.invitation!, + })); - const invitations: OrganizationInvitation[] = rows - .filter(r => r.invitation) - .map(r => ({ - ...r.invitation!, - })); + const members: MemberWithUser[] = rows + .filter((r) => r.member && r.user) + .map((r) => ({ + ...r.member!, + user: r.user!, + })); - const members: MemberWithUser[] = rows - .filter(r => r.member && r.user) - .map(r => ({ - ...r.member!, - user: r.user!, - })); - - return { - ...org, - invitations, - members, - }; + return { + ...org, + invitations, + members, + }; } - export function getFileExtension(dbType: string) { - switch (dbType) { - case "postgresql": - return ".dump"; - case "mysql": - return ".sql"; - default: - return ".dump"; - } + switch (dbType) { + case "postgresql": + return ".dump"; + case "mysql": + return ".sql"; + default: + return ".dump"; + } } -export function getFileHeadersBasedOnDbms(dbType: string): Record { - switch (dbType) { - case "postgresql": - return { - "application/octet-stream": [".dump"], - }; - case "mysql": - case "mariadb": - return { - "application/sql": [".sql"], - "application/x-sql": [".sql"], - }; - case "mongodb": - return { - "application/gzip": [".archive.gz"], - }; - case "firebird": - return { - "application/octet-stream": [".fbk"], - }; - case "valkey": - case "redis": - return { - "application/octet-stream": [".rdb"], - }; - case "sqlite": - return { - "application/octet-stream": [".backup"], - }; - case "mssql": - return { - "application/octet-stream": [".bacpac"], - }; - default: - throw new Error(`Unsupported database type: ${dbType}`); - - } +export function getFileHeadersBasedOnDbms( + dbType: string, +): Record { + switch (dbType) { + case "postgresql": + return { + "application/octet-stream": [".dump"], + }; + case "mysql": + case "mariadb": + return { + "application/sql": [".sql"], + "application/x-sql": [".sql"], + }; + case "mongodb": + return { + "application/gzip": [".archive.gz"], + }; + case "firebird": + return { + "application/octet-stream": [".fbk"], + }; + case "valkey": + case "redis": + return { + "application/octet-stream": [".rdb"], + }; + case "sqlite": + return { + "application/octet-stream": [".backup"], + }; + case "mssql": + return { + "application/octet-stream": [".bacpac"], + }; + default: + throw new Error(`Unsupported database type: ${dbType}`); + } } diff --git a/src/utils/init/setting.ts b/src/utils/init/setting.ts index 637f2d48..d69e5773 100644 --- a/src/utils/init/setting.ts +++ b/src/utils/init/setting.ts @@ -1,70 +1,68 @@ -import {db} from "@/db"; -import {env} from "@/env.mjs"; +import { db } from "@/db"; +import { env } from "@/env.mjs"; import * as drizzleDb from "@/db"; -import {eq} from "drizzle-orm"; -import {StorageProviderKind} from "@/features/storages/storages.types"; -import {withUpdatedAt} from "@/db/utils"; - +import { eq } from "drizzle-orm"; +import { StorageProviderKind } from "@/features/storages/storages.types"; +import { withUpdatedAt } from "@/db/utils"; export async function createSettingsIfNotExist() { - await db.transaction(async (tx) => { + await db.transaction(async (tx) => { + const systemSettingsValues = { + name: "system", + smtpPassword: env.SMTP_PASSWORD ?? null, + smtpFrom: env.SMTP_FROM ?? null, + smtpHost: env.SMTP_HOST ?? null, + smtpPort: env.SMTP_PORT ?? null, + smtpUser: env.SMTP_USER ?? null, + smtpSecure: env.SMTP_SECURE ?? false, + }; - const systemSettingsValues = { - name: "system", - smtpPassword: env.SMTP_PASSWORD ?? null, - smtpFrom: env.SMTP_FROM ?? null, - smtpHost: env.SMTP_HOST ?? null, - smtpPort: env.SMTP_PORT ?? null, - smtpUser: env.SMTP_USER ?? null, - smtpSecure: env.SMTP_SECURE ?? false, - }; + const [systemSetting] = await tx + .select() + .from(drizzleDb.schemas.setting) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .limit(1); - const [systemSetting] = await tx - .select() - .from(drizzleDb.schemas.setting) - .where(eq(drizzleDb.schemas.setting.name, "system")) - .limit(1); + const [finalSystemSetting] = systemSetting + ? await tx + .update(drizzleDb.schemas.setting) + .set(systemSettingsValues) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .returning() + : await tx + .insert(drizzleDb.schemas.setting) + .values(systemSettingsValues) + .returning(); - const [finalSystemSetting] = systemSetting - ? await tx - .update(drizzleDb.schemas.setting) - .set(systemSettingsValues) - .where(eq(drizzleDb.schemas.setting.name, "system")) - .returning() - : await tx - .insert(drizzleDb.schemas.setting) - .values(systemSettingsValues) - .returning(); + const localStorageValues = { + provider: "local" as StorageProviderKind, + enabled: true, + name: "System", + config: {}, + }; - const localStorageValues = { - provider: "local" as StorageProviderKind, - enabled: true, - name: "System", - config: {}, - }; + const [existingLocalStorage] = await tx + .select() + .from(drizzleDb.schemas.storageChannel) + .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) + .limit(1); - const [existingLocalStorage] = await tx - .select() - .from(drizzleDb.schemas.storageChannel) - .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) - .limit(1); + const [localStorage] = existingLocalStorage + ? await tx + .update(drizzleDb.schemas.storageChannel) + .set(localStorageValues) + .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) + .returning() + : await tx + .insert(drizzleDb.schemas.storageChannel) + .values(localStorageValues) + .returning(); - const [localStorage] = existingLocalStorage - ? await tx - .update(drizzleDb.schemas.storageChannel) - .set(localStorageValues) - .where(eq(drizzleDb.schemas.storageChannel.provider, "local")) - .returning() - : await tx - .insert(drizzleDb.schemas.storageChannel) - .values(localStorageValues) - .returning(); - - if (!finalSystemSetting.defaultStorageChannelId) { - await tx - .update(drizzleDb.schemas.setting) - .set(withUpdatedAt({ defaultStorageChannelId: localStorage.id })) - .where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id)); - } - }); -} \ No newline at end of file + if (!finalSystemSetting.defaultStorageChannelId) { + await tx + .update(drizzleDb.schemas.setting) + .set(withUpdatedAt({ defaultStorageChannelId: localStorage.id })) + .where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id)); + } + }); +} diff --git a/src/utils/init/user.ts b/src/utils/init/user.ts index d5c653e5..d7564f5c 100644 --- a/src/utils/init/user.ts +++ b/src/utils/init/user.ts @@ -1,71 +1,64 @@ -import {db} from "@/db"; +import { db } from "@/db"; import * as drizzleDb from "@/db"; -import {count, eq} from "drizzle-orm"; -import {logger} from "@/lib/logger"; +import { eq } from "drizzle-orm"; +import { logger } from "@/lib/logger"; import type { SignUpUser } from "@/types/auth"; -import {env} from "@/env.mjs"; -import {createUserDb} from "@/db/services/user"; +import { env } from "@/env.mjs"; +import { createUserDb } from "@/db/services/user"; -const log = logger.child({module: "init/user"}); +const log = logger.child({ module: "init/user" }); export async function createDefaultUser() { + const hasRequiredEnv = + env.AUTH_DEFAULT_USER && + env.AUTH_DEFAULT_PASSWORD && + env.AUTH_DEFAULT_USER_NAME; - const hasRequiredEnv = - env.AUTH_DEFAULT_USER && - env.AUTH_DEFAULT_PASSWORD && - env.AUTH_DEFAULT_USER_NAME; + if (!hasRequiredEnv) { + log.info("Default admin creation skipped: missing environment variables."); + return; + } - if (!hasRequiredEnv) { - log.info( - "Default admin creation skipped: missing environment variables.", - ); - return; - } + log.info("Checking default super admin..."); - log.info("Checking default super admin..."); + const existingSuperAdmin = await db.query.user.findFirst({ + where: eq(drizzleDb.schemas.user.role, "superadmin"), + }); - const existingSuperAdmin = await db.query.user.findFirst({ - where: eq(drizzleDb.schemas.user.role, "superadmin"), + if (existingSuperAdmin) { + log.info( + `CreateDefaultUser skipped: a superadmin already exists (${existingSuperAdmin.email}).`, + ); + return; + } + + const userData: SignUpUser = { + name: env.AUTH_DEFAULT_USER_NAME!, + email: env.AUTH_DEFAULT_USER!, + password: env.AUTH_DEFAULT_PASSWORD!, + theme: "system", + role: "superadmin", + emailVerified: true, + }; + + const newUser = await createUserDb(userData); + + if (newUser) { + log.info(`Default super admin created (${userData.email}).`); + + const defaultOrgSlug = "default"; + const defaultOrg = await db.query.organization.findFirst({ + where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug), }); - if (existingSuperAdmin) { - log.info( - `CreateDefaultUser skipped: a superadmin already exists (${existingSuperAdmin.email}).`, - ); - return; - } - - const userData: SignUpUser = { - name: env.AUTH_DEFAULT_USER_NAME!, - email: env.AUTH_DEFAULT_USER!, - password: env.AUTH_DEFAULT_PASSWORD!, - theme: "system", - role: "superadmin", - emailVerified: true, - }; - - - const newUser = await createUserDb(userData); - - if (newUser) { - log.info(`Default super admin created (${userData.email}).`); - - const defaultOrgSlug = "default"; - const defaultOrg = await db.query.organization.findFirst({ - where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug), - }); - - if (defaultOrg) { - await db.insert(drizzleDb.schemas.member).values({ - userId: newUser.id, - organizationId: defaultOrg.id, - role: "owner", - }); - } else { - log.warn( - "Default organization not found. Cannot assign member.", - ); - } - + if (defaultOrg) { + await db.insert(drizzleDb.schemas.member).values({ + userId: newUser.id, + organizationId: defaultOrg.id, + role: "owner", + }); + } else { + log.warn("Default organization not found. Cannot assign member."); } + } }