feat/onboarding (#337)

* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Théo LAGACHE
2026-06-27 17:08:38 +02:00
committed by GitHub
co-authored by Théo LAGACHE charles-gauthereau
parent 40d3228db6
commit 40247efddd
451 changed files with 15904 additions and 5054 deletions
@@ -1,6 +1,6 @@
"use server";
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/agents.schema";
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
import {z} from "zod";
import {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db";
@@ -11,7 +11,7 @@ import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {useAgentUpdateCheck} from "@/features/agents/use-agent-update-check";
import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
export type agentCardProps = {
data: AgentWith;
@@ -3,10 +3,10 @@
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/features/agents/agent-card-key";
import {AgentCardKey} from "@/features/agents/components/agent-card-key";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action";
import {getAgentAction} from "@/features/agents/actions/agents.action";
import {
Accordion,
AccordionContent,
@@ -16,8 +16,8 @@ import {
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/features/agents/agent-database-card";
import {HealthCheckGraph} from "@/features/database/health-grid";
import {AgentDatabaseCard} from "@/features/agents/components/agent-database-card";
import {HealthCheckGraph} from "@/features/database/components/health-grid";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = {
@@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {deleteAgentAction} from "@/features/agents/agent-delete.action";
import {deleteAgentAction} from "@/features/agents/actions/agent-delete.action";
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = {
@@ -7,7 +7,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {AgentForm} from "@/features/agents/agent-form";
import {AgentForm} from "@/features/agents/components/agent-form";
import {Button, buttonVariants} from "@/components/ui/button";
import {Plus} from "lucide-react";
import {GearIcon} from "@radix-ui/react-icons";
@@ -16,7 +16,7 @@ import {useState} from "react";
import {useRouter} from "next/navigation";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {AgentOrganisationForm} from "@/features/agents/agent-organizations.form";
import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
import {AgentWith} from "@/db/schema/08_agent";
type AgentDialogProps = {
@@ -15,9 +15,9 @@ import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/features/agents/agents.schema";
import {AgentSchema, AgentType} from "@/features/agents/schemas/agents.schema";
import {toast} from "sonner";
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
import {createAgentAction, updateAgentAction} from "@/features/agents/actions/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = {
@@ -8,8 +8,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {MultiSelect} from "@/components/common/multi-select";
import {toast} from "sonner";
import {AgentWith} from "@/db/schema/08_agent";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/agent-organizations.action";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/schemas/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/actions/agent-organizations.action";
type AgentOrganisationFormProps = {
@@ -9,7 +9,7 @@ import { Form } from "@/components/ui/form";
import { requestPasswordReset } from "@/lib/auth/auth-client";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import Link from "next/link";
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
import { ForgotPasswordSchema, ForgotPasswordType } from "../schemas/forgot-password-form.schema";
import { ArrowLeft } from "lucide-react";
export type ForgotPasswordFormProps = {
@@ -2,7 +2,7 @@
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import TwoFactorForm from "@/features/profile/2fa-form";
import TwoFactorForm from "@/features/profile/components/2fa-form";
export const GuardForm = () => {
const router = useRouter();
@@ -19,7 +19,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import {
LoginSchema,
type LoginType,
} from "@/features/auth/login-form.schema";
} from "@/features/auth/schemas/login-form.schema";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import { signIn } from "@/lib/auth/auth-client";
@@ -14,10 +14,10 @@ import {
import {
RegisterSchema,
RegisterType,
} from "@/features/auth/register-form.schema";
} from "@/features/auth/schemas/register-form.schema";
import { signUp } from "@/lib/auth/auth-client";
import { useZodForm } from "@/components/ui/form";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { FormField } from "@/components/ui/form";
import { FormItem } from "@/components/ui/form";
import { FormLabel } from "@/components/ui/form";
@@ -7,7 +7,7 @@ import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/compone
import {Form} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import Link from "next/link";
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
import {ResetPasswordSchema, ResetPasswordType} from "../schemas/reset-password-form.schema";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {useRouter, useSearchParams} from "next/navigation";
import {ArrowLeft} from "lucide-react";
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Icon } from "@iconify/react";
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();
const [isLoading, setIsLoading] = useState<string | null>(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",
});
} 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 (socialProviders.length === 0) return null;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button
key={provider.id}
variant="outline"
className="w-full gap-2"
onClick={() => handleSocialSignIn(provider)}
disabled={!!isLoading}
>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Icon } from "@iconify/react";
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();
const [isLoading, setIsLoading] = useState<string | null>(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",
});
}
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;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}
-88
View File
@@ -1,88 +0,0 @@
"use client";
import {Card} from "@/components/ui/card";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Badge} from "@/components/ui/badge";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
EditChannelButton
} from "@/features/channel/channel-edit-button";
import {ChannelKind, getChannelIcon} from "@/features/channel/channels-helpers";
import {
DeleteChannelButton
} from "@/features/channel/channel-delete-button";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined
};
export const ChannelCard = (props: ChannelCardProps) => {
const {data, organization, kind, adminView, defaultStorageChannelId} = props;
const isMobile = useIsMobile()
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider == "local";
return (
<div className="block transition-all duration-200 rounded-xl">
<Card className="flex flex-row justify-between p-4">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(data.provider)}
</div>
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
</div>
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
{isDefaultSystemStorage && (
<Badge variant="secondary" className="text-xs font-mono">
default
</Badge>
)}
</div>
</div>
{kind && (
<div className="flex items-center gap-2">
{(isOwned) && (
<>
<EditChannelButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
channel={data}
kind={kind}
/>
{!isLocalSystem && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
</div>
);
};
@@ -12,11 +12,11 @@ import {useIsMobile} from "@/hooks/use-mobile";
import {useEffect, useState} from "react";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelForm} from "@/features/channel/channel-form";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers";
import {ChannelForm} from "@/features/channel/components/channel-form";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers";
import {
ChannelOrganisationForm
} from "@/features/organizations/channels-organization-form";
} from "@/features/organizations/components/channels-organization-form";
type ChannelAddModalProps = {
@@ -0,0 +1,86 @@
"use client";
import { Card } from "@/components/ui/card";
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import { Badge } from "@/components/ui/badge";
import { OrganizationWithMembers } from "@/db/schema/03_organization";
import { truncateWords } from "@/utils/text";
import { useIsMobile } from "@/hooks/use-mobile";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { EditChannelButton } from "@/features/channel/components/channel-edit-button";
import {
ChannelKind,
getChannelIcon,
} from "@/features/channel/components/channels-helpers";
import { DeleteChannelButton } from "@/features/channel/components/channel-delete-button";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined;
};
export const ChannelCard = (props: ChannelCardProps) => {
const { data, organization, kind, defaultStorageChannelId } = props;
const isMobile = useIsMobile();
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider === "local";
return (
<div className="block transition-all duration-200 rounded-xl">
<Card className="flex flex-row justify-between p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(data.provider)}
</div>
<div
className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}
/>
</div>
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">
{isMobile ? truncateWords(data.name, 2) : data.name}
</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
{isDefaultSystemStorage && (
<Badge variant="secondary" className="text-xs font-mono">
default
</Badge>
)}
</div>
</div>
{kind && (
<div className="flex items-center gap-2">
{isOwned && (
<>
<EditChannelButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
channel={data}
kind={kind}
/>
{!isLocalSystem && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
</div>
);
};
@@ -5,12 +5,12 @@ import {useRouter} from "next/navigation";
import {Trash2} from "lucide-react";
import {
removeNotificationChannelAction,
} from "@/features/channel/notifications/channel.action";
} from "@/features/channel/components/notifications/channel.action";
import {toast} from "sonner";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers";
import {
removeStorageChannelAction
} from "@/features/channel/storages/channel.action";
} from "@/features/channel/components/storages/channel.action";
export type DeleteChannelButtonProps = {
channelId: string;
@@ -7,14 +7,14 @@ import {toast} from "sonner";
import {useState} from "react";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/features/channel/channels-helpers";
import {ChannelKind} from "@/features/channel/components/channels-helpers";
import {
updateNotificationChannelAction
} from "@/features/channel/notifications/channel.action";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
} from "@/features/channel/components/notifications/channel.action";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import {
updateStorageChannelAction
} from "@/features/channel/storages/channel.action";
} from "@/features/channel/components/storages/channel.action";
export type EditChannelButtonProps = {
channel: NotificationChannelWith | StorageChannelWith;
@@ -8,7 +8,7 @@ import {Input} from "@/components/ui/input";
import {
addStorageChannelAction, updateStorageChannelAction
} from "@/features/channel/storages/channel.action";
} from "@/features/channel/components/storages/channel.action";
import {toast} from "sonner";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Button} from "@/components/ui/button";
@@ -20,19 +20,19 @@ import {ArrowLeft} from "lucide-react";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
} from "@/features/channel/channel-form.schema";
} from "@/features/channel/schemas/channel-form.schema";
import {
ChannelKind,
renderChannelForm
} from "@/features/channel/channels-helpers";
import {storageProviders} from "@/features/channel/channels-storage-helper";
import {notificationProviders} from "@/features/channel/channels-notification-helper";
} from "@/features/channel/components/channels-helpers";
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
import {notificationProviders} from "@/features/channel/components/channels-notification-helper";
import {
ChannelTestButton
} from "@/features/channel/channel-test-button";
} from "@/features/channel/components/channel-test-button";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/features/channel/notifications/channel.action";
} from "@/features/channel/components/notifications/channel.action";
type NotifierFormProps = {
@@ -6,14 +6,14 @@ import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {dispatchNotification} from "@/features/notifications/notifications.dispatch";
import {EventPayload} from "@/features/notifications/notifications.types";
import {dispatchNotification} from "@/features/notifications/utils/notifications.dispatch";
import {EventPayload} from "@/features/notifications/types";
import {useIsMobile} from "@/hooks/use-mobile";
import {cn} from "@/lib/utils";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/features/channel/channels-helpers";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {ChannelKind} from "@/features/channel/components/channels-helpers";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
type NotifierTestChannelButtonProps = {
@@ -1,49 +1,49 @@
import {UseFormReturn} from "react-hook-form";
import {
NotifierSmtpForm
} from "@/features/channel/notifications/smtp.form";
} from "@/features/channel/components/notifications/smtp.form";
import {
NotifierSlackForm
} from "@/features/channel/notifications/slack.form";
} from "@/features/channel/components/notifications/slack.form";
import {
NotifierDiscordForm
} from "@/features/channel/notifications/discord.form";
} from "@/features/channel/components/notifications/discord.form";
import {
NotifierTelegramForm
} from "@/features/channel/notifications/telegram.form";
} from "@/features/channel/components/notifications/telegram.form";
import {
NotifierGotifyForm
} from "@/features/channel/notifications/gotify.form";
} from "@/features/channel/components/notifications/gotify.form";
import {
NotifierNtfyForm
} from "@/features/channel/notifications/ntfy.form";
} from "@/features/channel/components/notifications/ntfy.form";
import {
NotifierWebhookForm
} from "@/features/channel/notifications/webhook.form";
} from "@/features/channel/components/notifications/webhook.form";
import {
NotifierNextcloudForm
} from "@/features/channel/notifications/nextcloud.form";
} from "@/features/channel/components/notifications/nextcloud.form";
import {
NotifierPushoverForm
} from "@/features/channel/notifications/pushover.form";
} from "@/features/channel/components/notifications/pushover.form";
import {
notificationProviders,
} from "@/features/channel/channels-notification-helper";
} from "@/features/channel/components/channels-notification-helper";
import {
NotifierTeamsForm
} from "@/features/channel/notifications/teams.form";
import {storageProviders} from "@/features/channel/channels-storage-helper";
} from "@/features/channel/components/notifications/teams.form";
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
import {LucideProps} from "lucide-react";
import {
StorageS3Form
} from "@/features/channel/storages/s3.form";
} from "@/features/channel/components/storages/s3.form";
import {
StorageGoogleDriveForm
} from "@/features/channel/storages/google-drive/google-drive.form";
} from "@/features/channel/components/storages/google-drive/google-drive.form";
import {
StorageBlobForm
} from "@/features/channel/storages/az-blob.form";
} from "@/features/channel/components/storages/az-blob.form";
export type ChannelKind = "notification" | "storage";
@@ -1,6 +1,6 @@
import {Mail} from "lucide-react";
import type { SVGProps } from 'react';
import {ProviderIconTypes} from "@/features/channel/channels-helpers";
import {ProviderIconTypes} from "@/features/channel/components/channels-helpers";
@@ -5,12 +5,12 @@ import { useState } from "react";
import { EmptyStatePlaceholder } from "@/components/common/empty-state-placeholder";
import { OrganizationWithMembers } from "@/db/schema/03_organization";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { ChannelCard } from "@/features/channel/channel-card";
import { ChannelAddEditModal } from "@/features/channel/channel-add-edit-modal";
import { ChannelCard } from "@/features/channel/components/channel-card";
import { ChannelAddEditModal } from "@/features/channel/components/channel-add-edit-modal";
import {
ChannelKind,
getChannelTextBasedOnKind,
} from "@/features/channel/channels-helpers";
} from "@/features/channel/components/channels-helpers";
type ChannelsSectionProps = {
channels: NotificationChannelWith[] | StorageChannelWith[];
@@ -1,6 +1,6 @@
import {Server} from "lucide-react";
import type {SVGProps} from "react";
import {ProviderIconTypes} from "@/features/channel/channels-helpers";
import {ProviderIconTypes} from "@/features/channel/components/channels-helpers";
export const storageProviders: ProviderIconTypes[] = [
{value: "local", label: "Local", icon: Server},
@@ -0,0 +1,200 @@
"use server";
import { z } from "zod";
import { ServerActionResult } from "@/types/action-type";
import * as drizzleDb from "@/db";
import { userAction } from "@/lib/safe-actions/actions";
import { NotificationChannel } from "@/db/schema/09_notification-channel";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { NotificationChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
export const addNotificationChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
data: NotificationChannelFormSchema,
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { organizationId, data } = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.notificationChannel)
.values({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
})
.returning();
if (organizationId) {
await db
.insert(drizzleDb.schemas.organizationNotificationChannel)
.values({
organizationId,
notificationChannelId: channel.id,
});
}
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: "Notification channel has been successfully created.",
messageParams: { notificationChannelId: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to create notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { notificationChannelId: "" },
},
};
}
},
);
export const removeNotificationChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
notificationChannelId: z.string(),
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { organizationId, notificationChannelId } = parsedInput;
try {
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationNotificationChannel)
.where(
and(
eq(
drizzleDb.schemas.organizationNotificationChannel
.organizationId,
organizationId,
),
eq(
drizzleDb.schemas.organizationNotificationChannel
.notificationChannelId,
notificationChannelId,
),
),
);
}
const [deletedChannel] = await db
.delete(drizzleDb.schemas.notificationChannel)
.where(
eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId),
)
.returning();
if (!deletedChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
messageParams: { notificationChannelId: notificationChannelId },
},
};
}
return {
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON,
},
actionSuccess: {
message: "Notification channel has been successfully removed.",
messageParams: { notificationChannelId: notificationChannelId },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to remove notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { notificationChannelId: notificationChannelId },
},
};
}
},
);
export const updateNotificationChannelAction = userAction
.schema(
z.object({
id: z.string(),
data: NotificationChannelFormSchema,
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { id, data } = parsedInput;
try {
const [channel] = await db
.update(drizzleDb.schemas.notificationChannel)
.set(
withUpdatedAt({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
}),
)
.where(eq(drizzleDb.schemas.notificationChannel.id, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: { id: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
},
);
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendDiscord(
config: { discordWebhook: string },
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendGotify(
config: { gotifyServerUrl: string; gotifyAppToken: string },
@@ -1,15 +1,15 @@
"use server"
import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/types';
import {sendSlack} from './slack';
import {sendSmtp} from './smtp';
import {sendDiscord} from "@/features/channel/notifications/discord";
import {sendTelegram} from "@/features/channel/notifications/telegram";
import {sendGotify} from "@/features/channel/notifications/gotify";
import {sendNtfy} from "@/features/channel/notifications/ntfy";
import {sendWebhook} from "@/features/channel/notifications/webhook";
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
import {sendPushover} from "@/features/channel/notifications/pushover";
import {sendTeams} from "@/features/channel/notifications/teams"
import {sendDiscord} from "@/features/channel/components/notifications/discord";
import {sendTelegram} from "@/features/channel/components/notifications/telegram";
import {sendGotify} from "@/features/channel/components/notifications/gotify";
import {sendNtfy} from "@/features/channel/components/notifications/ntfy";
import {sendWebhook} from "@/features/channel/components/notifications/webhook";
import {sendNextcloud} from "@/features/channel/components/notifications/nextcloud";
import {sendPushover} from "@/features/channel/components/notifications/pushover";
import {sendTeams} from "@/features/channel/components/notifications/teams"
const handlers: Record<
ProviderKind,
@@ -1,5 +1,5 @@
import { createHmac, randomBytes } from "crypto";
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
type NextcloudConfig = {
nextcloudUrl: string;
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
const getPriority = (level?: string): number => {
switch (level) {
@@ -4,7 +4,7 @@ import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {PUSHOVER_PRIORITIES} from "@/features/channel/notifications/pushover.schema";
import {PUSHOVER_PRIORITIES} from "@/features/channel/components/notifications/pushover.schema";
type NotifierPushoverFormProps = {
form: UseFormReturn<any, any, any>;
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendPushover(
config: {
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendSlack(
config: { slackWebhook: string },
@@ -1,7 +1,7 @@
"use server";
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
import EmailNotification from "@/components/emails/email-notification";
export async function sendSmtp(
@@ -1,4 +1,4 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
@@ -1,4 +1,4 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
export async function sendTelegram(
config: {
@@ -1,4 +1,4 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
type WebhookConfig = {
webhookUrl: string;
@@ -6,7 +6,7 @@ import {
StorageMetaData,
StorageResult,
StorageUploadInput
} from '@/features/storages/storages.types';
} from '@/features/storages/types';
import {Readable} from "node:stream";
type BlobConfig = {
@@ -0,0 +1,189 @@
"use server";
import { z } from "zod";
import { ServerActionResult } from "@/types/action-type";
import * as drizzleDb from "@/db";
import { userAction } from "@/lib/safe-actions/actions";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { StorageChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
import { StorageChannel } from "@/db/schema/12_storage-channel";
export const addStorageChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
data: StorageChannelFormSchema,
}),
)
.action(
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
const { organizationId, data } = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.storageChannel)
.values({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
})
.returning();
if (organizationId) {
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
organizationId,
storageChannelId: channel.id,
});
}
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: "Storage channel has been successfully created.",
messageParams: { id: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to create storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
},
);
export const removeStorageChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
id: z.string(),
}),
)
.action(
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
const { organizationId, id } = parsedInput;
try {
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationStorageChannel)
.where(
and(
eq(
drizzleDb.schemas.organizationStorageChannel.organizationId,
organizationId,
),
eq(
drizzleDb.schemas.organizationStorageChannel.storageChannelId,
id,
),
),
);
}
const [deletedChannel] = await db
.delete(drizzleDb.schemas.storageChannel)
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
if (!deletedChannel) {
return {
success: false,
actionError: {
message: "Storage channel not found.",
status: 404,
messageParams: { id: id },
},
};
}
return {
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON,
},
actionSuccess: {
message: "Storage channel has been successfully removed.",
messageParams: { id: id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to remove storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: id },
},
};
}
},
);
export const updateStorageChannelAction = userAction
.schema(
z.object({
id: z.string(),
data: StorageChannelFormSchema,
}),
)
.action(
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
const { id, data } = parsedInput;
try {
const [channel] = await db
.update(drizzleDb.schemas.storageChannel)
.set(
withUpdatedAt({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
}),
)
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: `Storage channel "${channel.name}" has been successfully updated.`,
messageParams: { id: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
},
);
@@ -8,7 +8,7 @@ import {Button} from "@/components/ui/button";
import {PasswordInput} from "@/components/ui/password-input";
import {
googleDriveRefreshTokenAction
} from "@/features/channel/storages/google-drive/google-drive-refresh.action";
} from "@/features/channel/components/storages/google-drive/google-drive-refresh.action";
import {toast} from "sonner";
type StorageGoogleDriveFormProps = {
@@ -1,5 +1,5 @@
import {drive_v3, google} from "googleapis";
import {GoogleDriveConfig} from "@/features/channel/storages/google-drive/types";
import {GoogleDriveConfig} from "@/features/channel/components/storages/google-drive/types";
import Drive = drive_v3.Drive;
import {getServerUrl} from "@/utils/get-server-url";
@@ -6,15 +6,15 @@ import {
StorageMetaData,
StorageResult,
StorageUploadInput
} from '@/features/storages/storages.types';
import {GoogleDriveConfig} from "@/features/channel/storages/google-drive/types";
} from '@/features/storages/types';
import {GoogleDriveConfig} from "@/features/channel/components/storages/google-drive/types";
import {
ensureFolderPath,
findFileByName,
getGoogleDriveClient, resolveFilePath
} from "@/features/channel/storages/google-drive/helpers";
} from "@/features/channel/components/storages/google-drive/helpers";
import {Readable} from "node:stream";
import {generateFileUrl} from "@/features/storages/storages.helpers";
import {generateFileUrl} from "@/features/storages/utils/storages.helpers";
export async function uploadGoogleDrive(
config: GoogleDriveConfig,
@@ -2,18 +2,18 @@ import {
StorageProviderKind,
StorageInput,
StorageResult,
} from '@/features/storages/storages.types';
} from '@/features/storages/types';
import {uploadLocal, getLocal, deleteLocal, pingLocal, copyLocal} from './local';
import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/channel/storages/s3";
import {copyBlob, deleteBlob, getBlob, pingBlob, uploadBlob} from "@/features/channel/storages/az-blob";
import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/channel/components/storages/s3";
import {copyBlob, deleteBlob, getBlob, pingBlob, uploadBlob} from "@/features/channel/components/storages/az-blob";
import {
copyGoogleDrive,
deleteGoogleDrive,
getGoogleDrive,
pingGoogleDrive,
uploadGoogleDrive
} from "@/features/channel/storages/google-drive";
} from "@/features/channel/components/storages/google-drive";
type ProviderHandler = {
upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise<StorageResult>;
@@ -8,9 +8,9 @@ import {
StorageMetaData,
StorageResult,
StorageUploadInput,
} from '@/features/storages/storages.types';
} from '@/features/storages/types';
import fs from "node:fs";
import { generateFileUrl } from "@/features/storages/storages.helpers";
import { generateFileUrl } from "@/features/storages/utils/storages.helpers";
import { Readable } from "node:stream";
import { env } from "@/env.mjs";
@@ -6,7 +6,7 @@ import {
StorageMetaData,
StorageResult,
StorageUploadInput
} from '@/features/storages/storages.types';
} from '@/features/storages/types';
import {Readable} from "node:stream";
type S3Config = {
@@ -1,172 +0,0 @@
"use server";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {
NotificationChannelFormSchema
} from "@/features/channel/channel-form.schema";
export const addNotificationChannelAction = userAction.schema(
z.object({
organizationId: z.string().optional(),
data: NotificationChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {organizationId, data} = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.notificationChannel)
.values({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
})
.returning();
if (organizationId) {
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
organizationId,
notificationChannelId: channel.id,
});
}
return {
success: true,
value: {
...channel,
config: channel.config as JSON
},
actionSuccess: {
message: "Notification channel has been successfully created.",
messageParams: {notificationChannelId: channel.id},
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to create notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {notificationChannelId: ""},
},
};
}
});
export const removeNotificationChannelAction = userAction.schema(
z.object({
organizationId: z.string().optional(),
notificationChannelId: z.string(),
})
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {organizationId, notificationChannelId} = parsedInput;
try {
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationNotificationChannel)
.where(
and(
eq(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationId),
eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, notificationChannelId)
)
);
}
const [deletedChannel] = await db
.delete(drizzleDb.schemas.notificationChannel)
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
.returning();
if (!deletedChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
messageParams: {notificationChannelId: notificationChannelId},
},
};
}
return {
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON
},
actionSuccess: {
message: "Notification channel has been successfully removed.",
messageParams: {notificationChannelId: notificationChannelId},
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to remove notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {notificationChannelId: notificationChannelId},
},
};
}
});
export const updateNotificationChannelAction = userAction.schema(
z.object({
id: z.string(),
data: NotificationChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
const {id, data} = parsedInput;
try {
const [channel] = await db
.update(drizzleDb.schemas.notificationChannel)
.set(withUpdatedAt({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
}))
.where(eq(drizzleDb.schemas.notificationChannel.id, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON
},
actionSuccess: {
message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: {id: channel.id},
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
};
}
});
@@ -1,18 +1,18 @@
import {z} from "zod";
import {SlackChannelConfigSchema} from "@/features/channel/notifications/slack.schema";
import {SmtpChannelConfigSchema} from "@/features/channel/notifications/smtp.schema";
import {DiscordChannelConfigSchema} from "@/features/channel/notifications/discord.schema";
import {TelegramChannelConfigSchema} from "@/features/channel/notifications/telegram.schema";
import {GotifyChannelConfigSchema} from "@/features/channel/notifications/gotify.schema";
import {NtfyChannelConfigSchema} from "@/features/channel/notifications/ntfy.schema";
import {WebhookChannelConfigSchema} from "@/features/channel/notifications/webhook.schema";
import {NextcloudChannelConfigSchema} from "@/features/channel/notifications/nextcloud.schema";
import {PushoverChannelConfigSchema} from "@/features/channel/notifications/pushover.schema";
import {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema";
import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema";
import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema";
import {TeamsChannelConfigSchema} from "@/features/channel/notifications/teams.schema";
import {BlobChannelConfigSchema} from "@/features/channel/storages/az-blob.schema";
import {SlackChannelConfigSchema} from "@/features/channel/components/notifications/slack.schema";
import {SmtpChannelConfigSchema} from "@/features/channel/components/notifications/smtp.schema";
import {DiscordChannelConfigSchema} from "@/features/channel/components/notifications/discord.schema";
import {TelegramChannelConfigSchema} from "@/features/channel/components/notifications/telegram.schema";
import {GotifyChannelConfigSchema} from "@/features/channel/components/notifications/gotify.schema";
import {NtfyChannelConfigSchema} from "@/features/channel/components/notifications/ntfy.schema";
import {WebhookChannelConfigSchema} from "@/features/channel/components/notifications/webhook.schema";
import {NextcloudChannelConfigSchema} from "@/features/channel/components/notifications/nextcloud.schema";
import {PushoverChannelConfigSchema} from "@/features/channel/components/notifications/pushover.schema";
import {S3ChannelConfigSchema} from "@/features/channel/components/storages/s3.schema";
import {GoogleDriveChannelConfigSchema} from "@/features/channel/components/storages/google-drive/google-drive.schema";
import {LocalChannelConfigSchema} from "@/features/channel/components/storages/local.schema";
import {TeamsChannelConfigSchema} from "@/features/channel/components/notifications/teams.schema";
import {BlobChannelConfigSchema} from "@/features/channel/components/storages/az-blob.schema";
const BaseChannelFormSchema = z.object({
@@ -1,173 +0,0 @@
"use server";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {
StorageChannelFormSchema
} from "@/features/channel/channel-form.schema";
import {StorageChannel} from "@/db/schema/12_storage-channel";
export const addStorageChannelAction = userAction.schema(
z.object({
organizationId: z.string().optional(),
data: StorageChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
const {organizationId, data} = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.storageChannel)
.values({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null
})
.returning();
if (organizationId) {
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
organizationId,
storageChannelId: channel.id,
});
}
return {
success: true,
value: {
...channel,
config: channel.config as JSON
},
actionSuccess: {
message: "Storage channel has been successfully created.",
messageParams: {id: channel.id},
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to create storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
};
}
});
export const removeStorageChannelAction = userAction.schema(
z.object({
organizationId: z.string().optional(),
id: z.string(),
})
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
const {organizationId, id} = parsedInput;
try {
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationStorageChannel)
.where(
and(
eq(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationId),
eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, id)
)
);
}
const [deletedChannel] = await db
.delete(drizzleDb.schemas.storageChannel)
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
if (!deletedChannel) {
return {
success: false,
actionError: {
message: "Storage channel not found.",
status: 404,
messageParams: {id: id},
},
};
}
return {
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON
},
actionSuccess: {
message: "Storage channel has been successfully removed.",
messageParams: {id: id},
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to remove storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: id},
},
};
}
});
export const updateStorageChannelAction = userAction.schema(
z.object({
id: z.string(),
data: StorageChannelFormSchema
})
).action(async ({parsedInput}): Promise<ServerActionResult<StorageChannel>> => {
const {id, data} = parsedInput;
try {
const [channel] = await db
.update(drizzleDb.schemas.storageChannel)
.set(withUpdatedAt({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
}))
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON
},
actionSuccess: {
message: `Storage channel "${channel.name}" has been successfully updated.`,
messageParams: {id: channel.id},
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {id: ""},
},
};
}
});
@@ -2,8 +2,8 @@
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {db} from "@/db";
import {and, eq, isNull, ne, sql} from "drizzle-orm";
import * as drizzleDb from "@/db";
@@ -7,7 +7,7 @@ import * as drizzleDb from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
import {PolicySchema} from "@/features/database/channels-policy.schema";
import {PolicySchema} from "@/features/database/schemas/channels-policy.schema";
import {StoragePolicy} from "@/db/schema/13_storage-policy";
@@ -3,7 +3,7 @@
import { z } from "zod";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { DatabaseSchema } from "@/features/database/database-form.schema";
import { DatabaseSchema } from "@/features/database/schemas/database-form.schema";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {Database} from "@/db/schema/07_database";

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