feat: Working on storage modal in database page in order to add multiple backend storages. Adding a system to manage independently channels from organizations and from system admin.

This commit is contained in:
charlesgauthereau
2026-01-14 21:54:13 +01:00
parent d8e9e2dac3
commit 91f25bc25d
32 changed files with 7663 additions and 461 deletions
@@ -0,0 +1,332 @@
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {InfoIcon, Plus, Trash2} from "lucide-react";
import {useFieldArray} from "react-hook-form";
import {DatabaseWith} from "@/db/schema/07_database";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Label} from "@/components/ui/label";
import {Button} from "@/components/ui/button";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {Switch} from "@/components/ui/switch";
import {Card} from "@/components/ui/card";
import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile";
import {
ChannelKind,
getChannelIcon,
getChannelTextBasedOnKind
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {
EVENT_KIND_OPTIONS,
PoliciesSchema,
PoliciesType,
PolicyType
} from "@/components/wrappers/dashboard/database/channels-policy/policy.schema";
import {
createAlertPoliciesAction, createStoragePoliciesAction, deleteAlertPoliciesAction, deleteStoragePoliciesAction,
updateAlertPoliciesAction, updateStoragePoliciesAction
} from "@/components/wrappers/dashboard/database/channels-policy/policy.action";
type ChannelPoliciesFormProps = {
onSuccess?: () => void;
channels: NotificationChannel[] | StorageChannel[];
organizationId: string;
database: DatabaseWith;
kind: ChannelKind
};
export const ChannelPoliciesForm = ({
database,
channels,
organizationId,
onSuccess,
kind
}: ChannelPoliciesFormProps) => {
const router = useRouter();
const isMobile = useIsMobile();
const channelText = getChannelTextBasedOnKind(kind);
const organizationChannels = channels.map(c => c.id);
const filterByChannel = <T, K extends keyof T>(
items: T[] | undefined | null,
channelKey: K
): T[] => items?.filter(item => organizationChannels.includes(item[channelKey] as string)) ?? [];
const formattedAlertPolicies = filterByChannel(database.alertPolicies, "notificationChannelId")
.map(({notificationChannelId, eventKinds, enabled}) => ({
channelId: notificationChannelId,
eventKinds,
enabled
}));
const formattedStoragePolicies = filterByChannel(database.storagePolicies, "storageChannelId")
.map(({storageChannelId, enabled}) => ({
channelId: storageChannelId,
enabled
}));
const defaultPolicies: PolicyType[] =
kind === "notification"
? formattedAlertPolicies
: formattedStoragePolicies.map(({ channelId, enabled }) => ({ channelId, enabled }));
const form = useZodForm({
schema: PoliciesSchema,
defaultValues: { policies: defaultPolicies },
context: { kind }
});
const {fields, append, remove} = useFieldArray({ control: form.control, name: "policies" });
const addPolicy = () => append({channelId: "", eventKinds: [], enabled: true});
const removePolicyHandler = (index: number) => remove(index);
const onCancel = () => { form.reset(); onSuccess?.(); };
const mutation = useMutation({
mutationFn: async ({policies}: PoliciesType) => {
const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined });
const policiesToAdd = payload.filter(
(policy) => !defaultPolicies.some((a) => a.channelId === policy.channelId)
);
const policiesToRemove = defaultPolicies.filter(
(policy) => !payload.some((v) => v.channelId === policy.channelId)
);
const policiesToUpdate = payload.filter((policy) => {
const existing = defaultPolicies.find((a) => a.channelId === policy.channelId);
return existing &&
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
});
console.log(policiesToUpdate);
console.log(policiesToAdd);
const promises = kind === "notification"
? [
policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
policiesToUpdate.length > 0 ? await updateAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToUpdate}) : null,
policiesToRemove.length > 0 ? await deleteAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToRemove}) : null,
]
: [
policiesToAdd.length > 0 ? await createStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToAdd}) : null,
policiesToUpdate.length > 0 ? await updateStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToUpdate}) : null,
policiesToRemove.length > 0 ? await deleteStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToRemove}) : null,
];
const results = await Promise.allSettled(promises);
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (rejected) throw new Error(rejected.reason?.message || "Network or server error");
const failedActions = results
.filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled")
.map(r => r.value)
.filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false);
if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed");
return {success: true};
},
onSuccess: () => { toast.success("Policies saved successfully"); router.refresh(); },
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
});
return (
<Form form={form} className="flex flex-col gap-6" onSubmit={
async (values) => {
if (kind === "notification") {
for (const policy of values.policies) {
if (!policy.eventKinds || policy.eventKinds.length === 0) {
toast.error("Please select at least one event for all notification policies");
return;
}
}
}
await mutation.mutateAsync(values)
}
}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Configure {kind === "notification" ? "Alerts" : "Storages"}
</Label>
{!isMobile && (
<p className="text-xs text-muted-foreground mt-1">
{kind === "notification"
? "Choose which channels receive notifications for specific events."
: "Choose which storage to use with your database"}
</p>
)}
</div>
<Button
disabled={fields.length >= channels.length || channels.length === 0}
type="button"
size="sm"
className="h-8"
onClick={addPolicy}>
<Plus className="w-4 h-4 mr-1.5"/> Add Policy
</Button>
</div>
<div className="space-y-3 w-full">
{channels.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<InfoIcon className="h-8 w-8 text-muted-foreground/50"/>
<p className="font-medium text-sm text-foreground">No channels</p>
<p className="text-xs text-muted-foreground max-w-xs">
Please <Link href={`/dashboard/settings`} className="underline underline-offset-4 hover:text-primary transition-colors">
configure {channelText.toLowerCase()} channels
</Link> in your organization settings first.
</p>
</div>
) : fields.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Plus className="h-4 w-4 text-primary"/>
</div>
<p className="font-medium text-sm text-foreground">No policies</p>
<p className="text-xs text-muted-foreground">
{kind === "notification" ? `Click "Add Policy" to start receiving notifications.` : `Click "Add Policy" to use this storage.`}
</p>
</div>
) : (
<div className="grid gap-4">
{fields.map((field, index) => (
<Card key={field.id} className="p-4 transition-all hover:border-primary/50 relative group min-w-0 overflow-hidden">
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-2 items-start md:items-end flex-nowrap min-w-0 ">
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
{channelText} Channel
</Label>
<FormField
control={form.control}
name={`policies.${index}.channelId`}
render={({field}) => {
const selectedIds = form.watch("policies").map((a: PolicyType) => a.channelId).filter(Boolean);
const availableChannels = channels.filter(
(channel) => channel.id.toString() === field.value?.toString() || !selectedIds.includes(channel.id.toString())
);
const selectedChannel = channels.find(c => c.id === field.value);
return (
<FormItem className="space-y-0 min-w-0">
<Select onValueChange={field.onChange} value={field.value?.toString() || ""}>
<FormControl>
<SelectTrigger className="h-9 w-full bg-background border-input min-w-0">
<SelectValue placeholder="Select channel">
{selectedChannel && (
<div className="flex items-center gap-2 min-w-0 w-full">
<div className="flex items-center justify-center h-4 w-4 shrink-0">
{getChannelIcon(selectedChannel.provider)}
</div>
<span className="truncate font-medium text-sm min-w-0">
{selectedChannel.name}
</span>
<span className="shrink-0 text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground font-mono uppercase">
{selectedChannel.provider}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{availableChannels.map(channel => (
<SelectItem key={channel.id.toString()} value={channel.id.toString()}>
<div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">{getChannelIcon(channel.provider)}</div>
<span className="font-medium truncate min-w-0">{channel.name}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">({channel.provider})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage className="mt-1"/>
</FormItem>
);
}}
/>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">Status</Label>
<FormField
control={form.control}
name={`policies.${index}.enabled`}
render={({field}) => (
<FormItem className="space-y-0">
<FormControl>
<div className="flex items-center h-9 px-1 md:px-3 rounded-md border border-input bg-background justify-between min-w-0">
{!isMobile && (
<Label htmlFor={`switch-${index}`} className="text-xs cursor-pointer font-medium text-foreground mr-2">
{field.value ? "Active" : "Off"}
</Label>
)}
<Switch checked={field.value} onCheckedChange={field.onChange} id={`switch-${index}`} className="scale-75 origin-right"/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-1.5 shrink-0 mt-auto">
<Button type="button" variant="outline" size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 hover:bg-destructive/10 transition-colors border-input bg-background"
onClick={() => removePolicyHandler(index)}>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
</div>
{kind === "notification" && (
<FormField
control={form.control}
name={`policies.${index}.eventKinds`}
render={({field}) => (
<FormItem className="space-y-1.5 min-w-0">
<FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Trigger Events</FormLabel>
<FormControl>
<div className="max-w-full overflow-hidden">
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder={isMobile ? "Select events..." : "Select events to trigger notifications..."}
variant="inverted"
animation={0}
className="bg-background/50 w-full min-w-0 flex-wrap"
/>
</div>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
)}
</div>
</Card>
))}
</div>
)}
</div>
</div>
<div className="flex gap-3 justify-end pt-2 border-t mt-2">
<ButtonWithLoading variant="outline" type="button" onClick={onCancel}>Cancel</ButtonWithLoading>
<ButtonWithLoading isPending={mutation.isPending}>Save Changes</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,83 @@
"use client"
import {ReactNode, useState} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {DatabaseWith} from "@/db/schema/07_database";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {ChannelPoliciesForm} from "@/components/wrappers/dashboard/database/channels-policy/policy-form";
type ChannelPoliciesModalProps = {
database: DatabaseWith;
channels: NotificationChannel[] | StorageChannel[];
organizationId: string;
kind: ChannelKind;
icon: ReactNode;
}
export const ChannelPoliciesModal = ({icon, kind, database, channels, organizationId}: ChannelPoliciesModalProps) => {
const [open, setOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind)
const channelsFiltered = channels
.filter((channel) => channel.enabled)
const channelsIds = channelsFiltered
.map(channel => channel.id);
console.log(channelsIds);
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
console.log(channels);
console.log(database.storagePolicies);
console.log("activeAlertPolicies", activeAlertPolicies);
console.log("activeStoragePolicies", activeStoragePolicies);
const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
{icon}
{activePolicies && activePolicies.length > 0 && (
<Badge
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full p-0 text-[10px] flex items-center justify-center"
>
{activePolicies.length}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{channelText} policies</DialogTitle>
<DialogDescription>
Add and manage your database {channelText.toLowerCase()} policies
</DialogDescription>
<Separator className="mt-3 mb-3"/>
<ChannelPoliciesForm
organizationId={organizationId}
channels={channels}
database={database}
onSuccess={() => setOpen(false)}
kind={kind}
/>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,351 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
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 "@/components/wrappers/dashboard/database/channels-policy/policy.schema";
import {StoragePolicy} from "@/db/schema/13_storage-policy";
export const createAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string(),
alertPolicies: z.array(PolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
try {
const valuesToInsert = parsedInput.alertPolicies.map((policy) => ({
databaseId: parsedInput.databaseId,
notificationChannelId: policy.channelId,
eventKinds: policy.eventKinds!,
enabled: policy.enabled,
}));
const insertedPolicies = await db
.insert(drizzleDb.schemas.alertPolicy)
.values(valuesToInsert)
.returning();
return {
success: true,
value: insertedPolicies,
actionSuccess: {
message: `Alert policies successfully added`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to add policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const updateAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
alertPolicies: z.array(PolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
const {databaseId, alertPolicies} = parsedInput;
try {
const updatedPolicies = await db.transaction(async (tx) => {
const results: AlertPolicy[] = [];
for (const policy of alertPolicies) {
const {channelId: notificationChannelId, ...updateData} = policy;
const updated = await tx
.update(drizzleDb.schemas.alertPolicy)
.set(withUpdatedAt({
...updateData,
}))
.where(
and(
eq(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelId),
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId)
)
)
.returning();
if (updated[0]) {
results.push(updated[0]);
}
}
return results;
});
if (updatedPolicies.length === 0) {
return {
success: false,
actionError: {message: "No policies were updated."},
};
}
return {
success: true,
value: updatedPolicies,
actionSuccess: {
message: `Successfully updated ${updatedPolicies.length} alert policy(ies).`,
},
};
} catch (error) {
console.error("Update alert policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to update alert policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const deleteAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
alertPolicies: z.array(PolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
const {databaseId, alertPolicies} = parsedInput;
try {
const notificationChannelIds = alertPolicies.map((alertPolicy) => alertPolicy.channelId);
const policiesToDelete = await db
.select()
.from(drizzleDb.schemas.alertPolicy)
.where(
and(
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
)
);
if (policiesToDelete.length === 0) {
return {
success: false,
actionError: {message: "No alert policies found to delete."},
};
}
await db
.delete(drizzleDb.schemas.alertPolicy)
.where(
and(
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
)
);
return {
success: true,
value: policiesToDelete,
actionSuccess: {
message: `Successfully deleted ${policiesToDelete.length} alert policy(ies).`,
},
};
} catch (error) {
console.error("Delete alert policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to delete alert policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const createStoragePoliciesAction = userAction
.schema(
z.object({
databaseId: z.string(),
storagePolicies: z.array(PolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<StoragePolicy[]>> => {
try {
const valuesToInsert = parsedInput.storagePolicies.map((policy) => ({
databaseId: parsedInput.databaseId,
storageChannelId: policy.channelId,
enabled: policy.enabled,
}));
const insertedPolicies = await db
.insert(drizzleDb.schemas.storagePolicy)
.values(valuesToInsert)
.returning();
return {
success: true,
value: insertedPolicies,
actionSuccess: {
message: `Storage policies successfully added`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to add policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const updateStoragePoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
storagePolicies: z.array(PolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<StoragePolicy[]>> => {
const {databaseId, storagePolicies} = parsedInput;
try {
const updatedPolicies = await db.transaction(async (tx) => {
const results: StoragePolicy[] = [];
for (const policy of storagePolicies) {
const {channelId: storageChannelId, ...updateData} = policy;
const updated = await tx
.update(drizzleDb.schemas.storagePolicy)
.set(withUpdatedAt({
...updateData,
}))
.where(
and(
eq(drizzleDb.schemas.storagePolicy.storageChannelId, storageChannelId),
eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId)
)
)
.returning();
if (updated[0]) {
results.push(updated[0]);
}
}
return results;
});
if (updatedPolicies.length === 0) {
return {
success: false,
actionError: {message: "No policies were updated."},
};
}
return {
success: true,
value: updatedPolicies,
actionSuccess: {
message: `Successfully updated ${updatedPolicies.length} storage policy(ies).`,
},
};
} catch (error) {
console.error("Update storage policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to update storage policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const deleteStoragePoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
storagePolicies: z.array(PolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<StoragePolicy[]>> => {
const {databaseId, storagePolicies} = parsedInput;
try {
const storageChannelIds = storagePolicies.map((storagePolicy) => storagePolicy.channelId);
const policiesToDelete = await db
.select()
.from(drizzleDb.schemas.storagePolicy)
.where(
and(
eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.storagePolicy.storageChannelId, storageChannelIds)
)
);
if (policiesToDelete.length === 0) {
return {
success: false,
actionError: {message: "No storage policies found to delete."},
};
}
await db
.delete(drizzleDb.schemas.storagePolicy)
.where(
and(
eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.storagePolicy.storageChannelId, storageChannelIds)
)
);
return {
success: true,
value: policiesToDelete,
actionSuccess: {
message: `Successfully deleted ${policiesToDelete.length} storage policy(ies).`,
},
};
} catch (error) {
console.error("Delete storage policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to delete storage policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,27 @@
import {z} from "zod";
export const PolicySchema = z.object({
channelId: z.string().min(1, "Please select channel"),
eventKinds: z.array(z.enum([
'error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report'
]))
.optional(),
enabled: z.boolean().default(true),
});
export const PoliciesSchema = z.object({
policies: z.array(PolicySchema)
});
export type PoliciesType = z.infer<typeof PoliciesSchema>;
export type PolicyType = z.infer<typeof PolicySchema>;
export const EVENT_KIND_OPTIONS = [
{label: "Error Backup", value: "error_backup"},
{label: "Error Restore", value: "error_restore"},
{label: "Success Restore", value: "success_restore"},
{label: "Success Backup", value: "success_backup"},
{label: "Weekly Report", value: "weekly_report"},
];