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
@@ -0,0 +1,341 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
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";
import {Backup, Restoration} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
export const downloadBackupAction = userAction.schema(
z.object({
backupStorageId: z.string(),
})
).action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
const {backupStorageId} = parsedInput;
try {
const backupStorage = await db.query.backupStorage.findFirst({
where: eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
});
if (!backupStorage) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
};
}
if (backupStorage.status != "success" || !backupStorage.path) {
return {
success: false,
actionError: {
message: "An error occurred.",
status: 500,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const input: StorageInput = {
action: "get",
data: {
path: backupStorage.path,
signedUrl: true,
},
metadata: {
storageId: backupStorage.storageChannelId,
fileKind: "backups"
}
};
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
return {
success: result.success,
value: result.url,
actionSuccess: {
message: "Backup Storage downloaded successfully.",
messageParams: {backupStorageId: backupStorageId},
},
};
} catch (error) {
console.error("Error:", error);
return {
success: false,
actionError: {
message: "Failed to get presigned url.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {backupStorageId: backupStorageId},
},
};
}
});
export const createRestorationBackupAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
backupStorageId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
const restorationData = await db
.insert(drizzleDb.schemas.restoration)
.values({
databaseId: parsedInput.databaseId,
backupId: parsedInput.backupId,
backupStorageId: parsedInput.backupStorageId,
status: "waiting",
})
.returning()
.execute();
const createdRestoration = restorationData[0];
return {
success: true,
value: createdRestoration,
actionSuccess: {
message: "Restoration has been successfully created.",
messageParams: {restorationId: createdRestoration.id},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to create restoration.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error creating the restoration"},
},
};
}
});
export const deleteBackupStorageAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
backupStorageId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
const {backupId, databaseId, backupStorageId} = parsedInput;
try {
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const backupStorage = await db.query.backupStorage.findFirst({
where: eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
});
if (!backupStorage) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupStorageId: backupStorageId},
},
};
}
const [{count}] = await db
.select({count: sql<number>`count(*)`})
.from(drizzleDb.schemas.backupStorage)
.where(
and(
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
isNull(drizzleDb.schemas.backupStorage.deletedAt),
ne(drizzleDb.schemas.backupStorage.id, backupStorageId)
)
);
if (Number(count) === 0) {
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, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)))
}
await db
.update(drizzleDb.schemas.backupStorage)
.set(withUpdatedAt({
deletedAt: new Date(),
}))
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
if (backupStorage.status != "success" || !backupStorage.path) {
return {
success: false,
actionError: {
message: "An error occurred.",
status: 500,
messageParams: {backupStorageId: backupStorageId},
},
}
}
const input: StorageInput = {
action: "delete",
data: {
path: backupStorage.path,
},
};
await dispatchStorage(input, undefined, backupStorage.storageChannelId);
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup deleted successfully.`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
}
});
export const deleteBackupAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
const {backupId, databaseId} = parsedInput;
try {
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))
});
if (!backup) {
return {
success: false,
actionError: {
message: "Backup not found.",
status: 404,
messageParams: {backupId: backupId},
},
}
}
const backupStorages = await db.query.backupStorage.findMany({
where: eq(drizzleDb.schemas.backupStorage.backupId, backupId),
});
if (!backupStorages) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupId: 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, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)))
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
}
});
@@ -0,0 +1,42 @@
"use server";
import {z} from "zod";
import {db} from "@/db";
import {ServerActionResult} from "@/types/action-type";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
import {userAction} from "@/lib/safe-actions/actions";
export const backupButtonAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
try {
const [createdBackup] = await db
.insert(drizzleDb.schemas.backup)
.values({
databaseId: parsedInput,
status: "waiting",
})
.returning();
return {
success: true,
value: createdBackup,
actionSuccess: {
message: "Backup has been successfully created.",
messageParams: {databaseId: parsedInput},
},
};
} catch (error) {
console.error("Error creating backup:", error);
return {
success: false,
actionError: {
message: "Failed to create backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {databaseId: parsedInput},
},
};
}
});
@@ -0,0 +1,87 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
export const getDatabaseDataAction = userAction
.schema(
z.object({
databaseId: z.string(),
})
)
.action(async ({parsedInput}) => {
const {databaseId} = parsedInput;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[];
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
}) as RestorationWith[];
const totalBackups = backups.length;
const availableBackups = backups.filter(b => !b.deletedAt).length;
const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
// @ts-ignore
let activeOrganizationChannels = [];
// @ts-ignore
let activeOrganizationStorageChannels = [];
if (database?.project?.organizationId) {
const organizationChannels = await getOrganizationChannels(database.project.organizationId);
activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const organizationStorageChannels = await getOrganizationStorageChannels(database.project.organizationId);
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
}
return {
database,
backups,
restorations,
// @ts-ignore
activeOrganizationChannels,
// @ts-ignore
activeOrganizationStorageChannels,
stats: {
totalBackups,
availableBackups,
successRate
},
health: database ? await getHealthLast12hLogs({ id: database.id }) : []
};
});
@@ -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 "@/features/database/schemas/channels-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,36 @@
"use server";
import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
export const updateDatabaseBackupPolicyAction = userAction
.schema(
z.object({
databaseId: z.string(),
backupPolicy: z.string(),
})
)
.action(async ({parsedInput}) => {
const cronPolicy = parsedInput.backupPolicy === "" ? null : parsedInput.backupPolicy;
const [updated] = await db
.update(drizzleDb.schemas.database)
.set({
backupPolicy: cronPolicy,
})
.where(eq(drizzleDb.schemas.database.id, parsedInput.databaseId))
.returning()
.execute();
if (cronPolicy == null) {
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, parsedInput.databaseId)).execute();
}
return {
data: updated,
};
});
@@ -0,0 +1,22 @@
"use server";
import { z } from "zod";
import { db } from "@/db";
import { eq } from "drizzle-orm";
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";
export const updateDatabaseAction = userAction
.schema(
z.object({
id: z.string(),
data: DatabaseSchema,
})
)
.action(async ({ parsedInput }) => {
const [updated] = await db.update(drizzleDb.schemas.database).set(parsedInput.data).where(eq(drizzleDb.schemas.database.id, parsedInput.id)).returning().execute();
return updated;
});
@@ -0,0 +1,80 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/07_database";
import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm";
import {z} from "zod";
import {storeBackupFiles} from "@/features/storages/utils/storages.helpers";
import {getFileExtension} from "@/utils/common";
export const uploadBackupAction = userAction
.schema(z.instanceof(FormData))
.action(async ({parsedInput: formData}): Promise<ServerActionResult<Backup>> => {
try {
const file = formData.get("file") as File;
const databaseId = formData.get("databaseId") as string;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!database) {
return {
success: false,
actionError: {
message: "Database does not exist",
status: 500,
cause: "Unknown error",
},
};
}
const fileExtension = getFileExtension(database.dbms)
const arrayBuffer = await file.arrayBuffer();
const uuid = uuidv4();
const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(arrayBuffer);
const [backup] = await db
.insert(drizzleDb.schemas.backup)
.values({
imported: true,
status: 'ongoing',
databaseId: database.id,
})
.returning();
await storeBackupFiles(backup, database, buffer, fileName)
return {
success: true,
value: backup,
actionSuccess: {
message: `Backup successfully imported`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to import backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,98 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {Backup, Restoration} from "@/db/schema/07_database";
export const deleteRestoreAction = userAction
.schema(
z.object({
restorationId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
try {
await db
.delete(drizzleDb.schemas.restoration)
.where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)))
.execute();
await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
return {
success: true,
actionSuccess: {
message: "Restoration deleted successfully.",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete restoration.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the restoration"},
},
};
}
});
export const rerunRestorationAction = userAction
.schema(
z.object({
restorationId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
const [updatedRestoration] = await db
.update(drizzleDb.schemas.restoration)
.set({status: "waiting"})
.where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))
.returning()
.execute();
await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
if (!updatedRestoration) {
return {
success: false,
actionError: {
message: "Restoration not found.",
status: 404,
cause: "No restoration with the given ID exists.",
messageParams: {message: "Restoration not found"},
},
};
}
return {
success: true,
value: updatedRestoration,
actionSuccess: {
message: "Restoration has been requeued.",
messageParams: {restorationId: updatedRestoration.id},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to rerun restoration.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error updating the restoration"},
},
};
}
});
@@ -0,0 +1,67 @@
"use server"
import {z} from "zod"
import {db} from "@/db"
import {eq} from "drizzle-orm"
import * as drizzleDb from "@/db";
import {
RetentionSettingsSchema
} from "@/features/database/schemas/retention-policy.schema";
import {userAction} from "@/lib/safe-actions/actions";
export const updateOrCreateBackupRetentionPolicyAction = userAction
.schema(
z.object({
databaseId: z.string(),
settings: RetentionSettingsSchema,
})
)
.action(async ({parsedInput}) => {
const {databaseId, settings} = parsedInput
// Check if a retention policy already exists
const existing = await db
.select()
.from(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
.limit(1)
let updated
if (existing.length > 0) {
// Update existing policy
updated = await db
.update(drizzleDb.schemas.retentionPolicy)
.set({
type: settings.type,
count: settings.count,
days: settings.days,
gfsDaily: settings.gfs.daily,
gfsWeekly: settings.gfs.weekly,
gfsMonthly: settings.gfs.monthly,
gfsYearly: settings.gfs.yearly,
})
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
.returning()
} else {
// Insert new policy
updated = await db
.insert(drizzleDb.schemas.retentionPolicy)
.values({
databaseId,
type: settings.type ?? "gfs",
count: settings.count,
days: settings.days,
gfsDaily: settings.gfs.daily,
gfsWeekly: settings.gfs.weekly,
gfsMonthly: settings.gfs.monthly,
gfsYearly: settings.gfs.yearly,
})
.returning()
}
return {
data: updated[0],
}
})