Merge branch 'feature'

This commit is contained in:
charlesgauthereau
2026-01-20 19:50:40 +01:00
27 changed files with 4809 additions and 141 deletions
@@ -26,7 +26,7 @@ import {
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {sendEmail} from "@/lib/email/email-helper";
import {sendEmail} from "@/lib/email";
import {render} from "@react-email/components";
import EmailSettingsTest from "@/components/emails/email-settings-test";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
@@ -18,7 +18,7 @@ import {userAction} from "@/lib/safe-actions/actions";
import {
addMemberOrganizationAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import {sendEmail} from "@/lib/email/email-helper";
import {sendEmail} from "@/lib/email";
import EmailCreateUser from "@/components/emails/email-create-user";
import {SignUpUser} from "@/types/auth";
import {createUserDb} from "@/db/services/user";
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'mongodb';
@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "smtp_secure" boolean;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -211,6 +211,20 @@
"when": 1768743644834,
"tag": "0029_lowly_white_tiger",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1768763883339,
"tag": "0030_dizzy_morlocks",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1768931603846,
"tag": "0031_chemical_edwin_jarvis",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -1,4 +1,4 @@
import {pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core";
import {boolean, pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core";
import {typeStorageEnum} from "./types";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
@@ -19,6 +19,7 @@ export const setting = pgTable("settings", {
smtpHost: varchar("smtp_host", {length: 255}),
smtpPort: varchar("smtp_port", {length: 255}),
smtpUser: varchar("smtp_user", {length: 255}),
smtpSecure: boolean("smtp_secure"),
defaultStorageChannelId: uuid('default_storage_channel_id')
.references(() => storageChannel.id, {onDelete: "set null"}),
...timestamps
+4 -4
View File
@@ -1,8 +1,8 @@
import { pgEnum } from "drizzle-orm/pg-core";
import { createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+2
View File
@@ -22,6 +22,7 @@ export const env = createEnv({
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_SECURE: z.coerce.boolean().default(true),
AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(),
@@ -60,6 +61,7 @@ export const env = createEnv({
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
+1 -1
View File
@@ -9,7 +9,7 @@ import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} fro
import {headers} from "next/headers";
import {count, eq} from "drizzle-orm";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {sendEmail} from "@/lib/email/email-helper";
import {sendEmail} from "@/lib/email";
import {render} from "@react-email/render";
import {AuthProviderConfig, SUPPORTED_PROVIDERS} from "../../../portabase.config";
import {withUpdatedAt} from "@/db/utils";
-104
View File
@@ -1,104 +0,0 @@
"use server";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import nodemailer from "nodemailer";
import * as drizzleDb from "@/db";
type Payload = {
to: string;
from?: string;
subject: string;
html: any;
};
type Server = {
host: string;
port: number;
user: string;
pass: string;
from: string;
};
type EmailCustomProps = {
data: Payload;
server: Server;
};
type EmailMassProps = {
data: Payload;
servers: Server[];
};
export const sendEmail = async (data: Payload) => {
const settings = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.then((res) => res[0]);
if (!settings) {
throw new Error("SMTP system settings not found.");
}
if (!settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPassword || !settings.smtpFrom) {
console.warn("Incomplete SMTP settings. Email not sent.");
return;
}
const emailsArray = data.to.split(",")
.map(email => email.trim());
const transporter = nodemailer.createTransport({
pool: true,
host: settings.smtpHost ?? "",
port: parseInt(settings.smtpPort ?? "587"),
secure: true,
auth: {
user: settings.smtpUser ?? "",
pass: settings.smtpPassword ?? "",
},
});
return await transporter.sendMail({
...data,
to: emailsArray,
from: settings.smtpFrom ?? undefined,
});
};
export const sendCustomEmail = async (data: EmailCustomProps) => {
const transporter = nodemailer.createTransport({
secure: true,
replyTo: data.server.user,
host: data.server.host,
port: data.server.port,
auth: {
user: data.server.user,
pass: data.server.pass,
},
});
return await transporter.sendMail({
...data.data,
});
};
export const sendMassEmail = async (data: EmailMassProps) => {
for (const server of data.servers) {
const transporter = nodemailer.createTransport({
secure: true,
host: server.host,
port: server.port,
auth: {
user: server.user,
pass: server.pass,
},
});
return await transporter.sendMail({
...data.data,
});
}
};
+15
View File
@@ -0,0 +1,15 @@
import nodemailer from "nodemailer";
export const createTransporter = (server: Server) => {
const portNumber = Number(server.port);
return nodemailer.createTransport({
pool: true,
host: server.host,
port: portNumber,
secure: server.secure ?? portNumber === 465,
auth: {
user: server.user,
pass: server.pass,
},
});
};
+41
View File
@@ -0,0 +1,41 @@
"use server"
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {createTransporter} from "@/lib/email/helpers";
export const sendEmail = async (data: Payload) => {
const settings = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.then((res) => res[0]);
if (!settings) {
throw new Error("SMTP system settings not found.");
}
if (!settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPassword || !settings.smtpFrom) {
console.warn("Incomplete SMTP settings. Email not sent.");
return;
}
const emailsArray = data.to.split(",")
.map(email => email.trim());
const transporter = createTransporter({
host: settings.smtpHost,
port: Number(settings.smtpPort),
user: settings.smtpUser,
pass: settings.smtpPassword,
from: settings.smtpFrom,
secure: settings.smtpSecure ?? false,
});
await transporter.verify();
return await transporter.sendMail({
...data,
to: emailsArray,
from: settings.smtpFrom ?? undefined,
});
};
+19
View File
@@ -0,0 +1,19 @@
"use server";
type Payload = {
to: string;
from?: string;
subject: string;
html: any;
};
type Server = {
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
};
+3 -3
View File
@@ -18,9 +18,9 @@ export const retentionCleanTask = async () => {
},
},
});
console.log(`Retention databases number: ${databases.length}`);
for (const db of databases) {
if (!db.retentionPolicy) continue; // no policy = skip
if (!db.retentionPolicy) continue;
await enforceRetention(db.id, db.retentionPolicy);
}
} catch (e: any) {
@@ -33,7 +33,7 @@ export async function enforceRetention(
databaseId: string,
policy: typeof retentionPolicy.$inferSelect
) {
console.log(`Retention started for ${databaseId}`);
switch (policy.type) {
case "count":
await enforceRetentionCount(databaseId, policy.count ?? 7);
+12 -2
View File
@@ -2,8 +2,10 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, desc, eq, isNull} from "drizzle-orm";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {toast} from "sonner";
export async function enforceRetentionCount(databaseId: string, count: number) {
console.log(`[Retention Count] - ${databaseId} : started`);
const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
@@ -16,12 +18,20 @@ export async function enforceRetentionCount(databaseId: string, count: number) {
}
});
const toDelete = backups.slice(count); // keep first `count`, delete rest
const toDelete = backups.slice(count);
console.log(`[Retention Count] - ${databaseId} : ${toDelete.length} backups to delete`);
for (const b of toDelete) {
await deleteBackupCronAction({
const result = await deleteBackupCronAction({
backupId: b.id,
databaseId: b.databaseId,
});
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
}
}
+9 -1
View File
@@ -4,6 +4,7 @@ import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
export async function enforceRetentionDays(databaseId: string, days: number) {
console.log(`Enforce Retention Days starting for ${databaseId}`);
const cutoff = new Date(Date.now() - days * 86400000);
const expiredBackups = await db.query.backup.findMany({
@@ -22,11 +23,18 @@ export async function enforceRetentionDays(databaseId: string, days: number) {
});
for (const backup of expiredBackups) {
await deleteBackupCronAction({
const result = await deleteBackupCronAction({
backupId: backup.id,
databaseId: backup.databaseId,
});
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`);
} else {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`);
}
}
}
+11 -2
View File
@@ -10,6 +10,8 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
monthly: number;
yearly: number;
}) {
console.log(`Enforce Retention GFS starting for ${databaseId}`);
const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
@@ -30,7 +32,7 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id);
});
const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i), { weekStartsOn: 1 }));
const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i), {weekStartsOn: 1}));
weekStartDates.forEach((weekStart) => {
const backupOfWeek = backups.find(
(b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1)
@@ -60,10 +62,17 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
for (const b of backups) {
if (!toKeep.has(b.id)) {
await deleteBackupCronAction({
const result = await deleteBackupCronAction({
backupId: b.id,
databaseId: b.databaseId,
});
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
}
}
}
+3 -13
View File
@@ -22,9 +22,10 @@ export const deleteBackupCronAction = action
try {
const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.backupId))
where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))
});
if (!backup) {
return {
success: false,
@@ -42,17 +43,6 @@ export const deleteBackupCronAction = action
});
if (!backupStorages) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupId: parsedInput.backupId},
},
};
}
for (const backupStorage of backupStorages) {
await db
@@ -81,7 +71,7 @@ export const deleteBackupCronAction = action
.set(withUpdatedAt({
deletedAt: new Date(),
}))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.backupId)))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
return {
success: true,
+2 -3
View File
@@ -4,8 +4,6 @@ import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {retentionJob} from "@/lib/tasks";
import {generateRSAKeys} from "@/utils/rsa-keys";
import {Provider} from "react";
import type {ProviderKind} from "@/features/notifications/types";
import {StorageProviderKind} from "@/features/storages/types";
@@ -34,6 +32,7 @@ async function createSettingsIfNotExist() {
smtpHost: env.SMTP_HOST ?? null,
smtpPort: env.SMTP_PORT ?? null,
smtpUser: env.SMTP_USER ?? null,
smtpSecure: env.SMTP_SECURE,
};
const [existing] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
@@ -59,7 +58,7 @@ async function createSettingsIfNotExist() {
console.log("====Local Storage : Create ====");
const [localChannelCreated] = await db.insert(drizzleDb.schemas.storageChannel).values(localChannelValues).returning();
if (localChannelCreated){
if (localChannelCreated) {
await db.update(drizzleDb.schemas.setting).set({
defaultStorageChannelId: localChannelCreated.id,
}).where(eq(drizzleDb.schemas.setting.name, "system"));