610 lines
24 KiB
TypeScript
610 lines
24 KiB
TypeScript
import { z } from "zod";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { COOKIE_NAME } from "@shared/const";
|
|
import { getSessionCookieOptions } from "./_core/cookies";
|
|
import { systemRouter } from "./_core/systemRouter";
|
|
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
|
import {
|
|
getVeilleItems,
|
|
getVeilleDistinctValues,
|
|
getAapItems,
|
|
getAapDistinctValues,
|
|
purgeVeilleItems,
|
|
purgeAapItems,
|
|
getAllSettings,
|
|
setSettings,
|
|
getImportLogs,
|
|
getImportStats,
|
|
getLocalUsers,
|
|
createLocalUser,
|
|
updateLocalUser,
|
|
deleteLocalUser,
|
|
createIdea,
|
|
getAllIdeas,
|
|
getIdeasByUser,
|
|
repondreIdea,
|
|
updateIdeaStatut,
|
|
getRssFeeds,
|
|
getRssFeedById,
|
|
createRssFeed,
|
|
updateRssFeed,
|
|
deleteRssFeed,
|
|
getRssSettings,
|
|
saveRssSettings,
|
|
} from "./db";
|
|
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
|
|
import { scheduleDailyImport } from "./_core/index";
|
|
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
|
|
import { classifyArticle } from "./aiClassifier";
|
|
import { getDb } from "./db";
|
|
import { veilleItems, aapItems, articleReads } from "../drizzle/schema";
|
|
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
|
|
|
|
// ─── Middleware admin ─────────────────────────────────────────────────────────
|
|
|
|
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
|
if (ctx.user.role !== "admin") {
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux administrateurs" });
|
|
}
|
|
return next({ ctx });
|
|
});
|
|
|
|
// ─── Router principal ─────────────────────────────────────────────────────────
|
|
|
|
export const appRouter = router({
|
|
system: systemRouter,
|
|
|
|
// ─── Auth ───────────────────────────────────────────────────────────────────
|
|
auth: router({
|
|
me: publicProcedure.query((opts) => opts.ctx.user),
|
|
logout: publicProcedure.mutation(({ ctx }) => {
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
|
return { success: true } as const;
|
|
}),
|
|
// Connexion locale
|
|
localLogin: publicProcedure
|
|
.input(z.object({ identifier: z.string().min(1), password: z.string().min(1) }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const result = await loginLocalUser(input.identifier, input.password);
|
|
// Stocker le token dans un cookie
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.cookie("veille_local_auth", result.token, {
|
|
...cookieOptions,
|
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
});
|
|
return { success: true, user: result.user };
|
|
}),
|
|
localLogout: publicProcedure.mutation(({ ctx }) => {
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 });
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ─── Veille ─────────────────────────────────────────────────────────────────
|
|
veille: router({
|
|
list: publicProcedure
|
|
.input(
|
|
z.object({
|
|
typeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).optional(),
|
|
categorie: z.string().optional(),
|
|
niveau: z.string().optional(),
|
|
territoire: z.string().optional(),
|
|
search: z.string().optional(),
|
|
dateFrom: z.date().optional(),
|
|
dateTo: z.date().optional(),
|
|
page: z.number().int().positive().default(1),
|
|
pageSize: z.number().int().positive().max(200).default(50),
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
return getVeilleItems(input);
|
|
}),
|
|
|
|
filters: publicProcedure.query(async () => {
|
|
return getVeilleDistinctValues();
|
|
}),
|
|
purge: adminProcedure.mutation(async () => {
|
|
const count = await purgeVeilleItems();
|
|
return { success: true, deleted: count };
|
|
}),
|
|
|
|
reclassifyWithAI: adminProcedure
|
|
.input(z.object({ limit: z.number().int().positive().max(200).default(50) }))
|
|
.mutation(async ({ input }) => {
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
|
|
|
// Récupérer les articles non encore classés par l'IA
|
|
const rows = await db
|
|
.select()
|
|
.from(veilleItems)
|
|
.where(or(isNull(veilleItems.iaClassifiedBy), isNull(veilleItems.iaRelevant)))
|
|
.limit(input.limit);
|
|
|
|
let processed = 0;
|
|
let errors = 0;
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
const aiResult = await classifyArticle(
|
|
row.titre || "",
|
|
row.resume || "",
|
|
() => ({ typeVeille: (row.typeVeille || "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle" })
|
|
);
|
|
await db
|
|
.update(veilleItems)
|
|
.set({
|
|
typeVeille: (aiResult.typeVeille ?? row.typeVeille ?? "informationnelle") as any,
|
|
iaRelevant: aiResult.relevant,
|
|
iaClassifiedBy: aiResult.classifiedBy,
|
|
iaReason: aiResult.reason,
|
|
})
|
|
.where(eqDrizzle(veilleItems.id, row.id));
|
|
processed++;
|
|
} catch {
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
return { processed, errors, total: rows.length };
|
|
}),
|
|
|
|
// ─── Marquage lu/non lu ──────────────────────────────────────────────────────────────────────
|
|
markAsRead: protectedProcedure
|
|
.input(z.object({ articleId: z.number().int().positive() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
|
// Insérer seulement si pas déjà lu (ignore le doublon)
|
|
try {
|
|
await db.insert(articleReads).values({
|
|
userId: ctx.user.id,
|
|
articleType: "veille",
|
|
articleId: input.articleId,
|
|
});
|
|
} catch { /* doublon = déjà lu, on ignore */ }
|
|
return { success: true };
|
|
}),
|
|
|
|
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
|
// Récupérer tous les IDs veille
|
|
const allItems = await db.select({ id: veilleItems.id }).from(veilleItems);
|
|
const allIds = allItems.map((r: { id: number }) => r.id);
|
|
// Trouver ceux déjà lus
|
|
const alreadyRead = await db
|
|
.select({ articleId: articleReads.articleId })
|
|
.from(articleReads)
|
|
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
|
|
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
|
|
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
|
|
userId: ctx.user.id, articleType: "veille" as const, articleId: id,
|
|
}));
|
|
if (toInsert.length > 0) {
|
|
await db.insert(articleReads).values(toInsert);
|
|
}
|
|
return { success: true, marked: toInsert.length };
|
|
}),
|
|
|
|
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
|
const db = await getDb();
|
|
if (!db) return { count: 0 };
|
|
const totalRows = await db.select({ cnt: count() }).from(veilleItems);
|
|
const total = totalRows[0]?.cnt ?? 0;
|
|
const readRows = await db
|
|
.select({ cnt: count() })
|
|
.from(articleReads)
|
|
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
|
|
const read = readRows[0]?.cnt ?? 0;
|
|
return { count: Math.max(0, total - read) };
|
|
}),
|
|
}),
|
|
// ─── AAPP ────────────────────────────────────────────────────────────────────
|
|
aap: router({
|
|
list: publicProcedure
|
|
.input(
|
|
z.object({
|
|
categorie: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(),
|
|
region: z.string().optional(),
|
|
departement: z.string().optional(),
|
|
search: z.string().optional(),
|
|
dateFrom: z.date().optional(),
|
|
dateTo: z.date().optional(),
|
|
clotureFrom: z.date().optional(),
|
|
clotureTo: z.date().optional(),
|
|
page: z.number().int().positive().default(1),
|
|
pageSize: z.number().int().positive().max(200).default(50),
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
return getAapItems(input);
|
|
}),
|
|
|
|
filters: publicProcedure.query(async () => {
|
|
return getAapDistinctValues();
|
|
}),
|
|
purge: adminProcedure.mutation(async () => {
|
|
const count = await purgeAapItems();
|
|
return { success: true, deleted: count };
|
|
}),
|
|
|
|
reclassifyWithAI: adminProcedure
|
|
.input(z.object({ limit: z.number().int().positive().max(200).default(50) }))
|
|
.mutation(async ({ input }) => {
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(aapItems)
|
|
.where(or(isNull(aapItems.iaClassifiedBy), isNull(aapItems.iaRelevant)))
|
|
.limit(input.limit);
|
|
|
|
let processed = 0;
|
|
let errors = 0;
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
const aiResult = await classifyArticle(
|
|
row.titre || "",
|
|
"",
|
|
() => ({ typeVeille: "informationnelle" as const })
|
|
);
|
|
await db
|
|
.update(aapItems)
|
|
.set({
|
|
iaRelevant: aiResult.relevant,
|
|
iaClassifiedBy: aiResult.classifiedBy,
|
|
iaReason: aiResult.reason,
|
|
})
|
|
.where(eqDrizzle(aapItems.id, row.id));
|
|
processed++;
|
|
} catch {
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
return { processed, errors, total: rows.length };
|
|
}),
|
|
|
|
// ─── Marquage lu/non lu AAP ──────────────────────────────────────────────────────────────────────
|
|
markAsRead: protectedProcedure
|
|
.input(z.object({ articleId: z.number().int().positive() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
|
try {
|
|
await db.insert(articleReads).values({
|
|
userId: ctx.user.id,
|
|
articleType: "aap",
|
|
articleId: input.articleId,
|
|
});
|
|
} catch { /* doublon = déjà lu, on ignore */ }
|
|
return { success: true };
|
|
}),
|
|
|
|
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
|
const allItems = await db.select({ id: aapItems.id }).from(aapItems);
|
|
const allIds = allItems.map((r: { id: number }) => r.id);
|
|
const alreadyRead = await db
|
|
.select({ articleId: articleReads.articleId })
|
|
.from(articleReads)
|
|
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
|
|
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
|
|
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
|
|
userId: ctx.user.id, articleType: "aap" as const, articleId: id,
|
|
}));
|
|
if (toInsert.length > 0) {
|
|
await db.insert(articleReads).values(toInsert);
|
|
}
|
|
return { success: true, marked: toInsert.length };
|
|
}),
|
|
|
|
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
|
const db = await getDb();
|
|
if (!db) return { count: 0 };
|
|
const totalRows = await db.select({ cnt: count() }).from(aapItems);
|
|
const total = totalRows[0]?.cnt ?? 0;
|
|
const readRows = await db
|
|
.select({ cnt: count() })
|
|
.from(articleReads)
|
|
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
|
|
const read = readRows[0]?.cnt ?? 0;
|
|
return { count: Math.max(0, total - read) };
|
|
}),
|
|
}),
|
|
// ─── Importt ─────────────────────────────────────────────────────────────────
|
|
import: router({
|
|
run: adminProcedure
|
|
.input(z.object({ type: z.enum(["veille", "aap", "all"]).default("all") }))
|
|
.mutation(async ({ input }) => {
|
|
const config = await getImportConfig();
|
|
if (input.type === "all") return runFullImport();
|
|
if (input.type === "veille") return { veille: await importVeille(config) };
|
|
return { aap: await importAAP(config) };
|
|
}),
|
|
|
|
logs: adminProcedure
|
|
.input(z.object({ page: z.number().int().positive().default(1), pageSize: z.number().int().positive().max(100).default(20) }))
|
|
.query(async ({ input }) => {
|
|
const allLogs = await getImportLogs(500);
|
|
const start = (input.page - 1) * input.pageSize;
|
|
const logs = allLogs.slice(start, start + input.pageSize);
|
|
const stats = await getImportStats();
|
|
return { logs, total: allLogs.length, stats };
|
|
}),
|
|
|
|
stats: publicProcedure.query(async () => {
|
|
return getImportStats();
|
|
}),
|
|
}),
|
|
|
|
// ─── Paramètres ─────────────────────────────────────────────────────────────
|
|
settings: router({
|
|
get: adminProcedure.query(async () => {
|
|
const all = await getAllSettings();
|
|
// Masquer les mots de passe
|
|
const safe = { ...all };
|
|
if (safe.ftp_password) safe.ftp_password = "••••••••";
|
|
if (safe.onedrive_token) safe.onedrive_token = "••••••••";
|
|
if (safe.sharepoint_token) safe.sharepoint_token = "••••••••";
|
|
return safe;
|
|
}),
|
|
|
|
save: adminProcedure
|
|
.input(
|
|
z.object({
|
|
source_type: z.enum(["local", "onedrive", "ftp", "sharepoint"]),
|
|
veille_file_path: z.string().optional(),
|
|
aap_file_path: z.string().optional(),
|
|
ftp_host: z.string().optional(),
|
|
ftp_port: z.string().optional(),
|
|
ftp_user: z.string().optional(),
|
|
ftp_password: z.string().optional(),
|
|
ftp_secure: z.string().optional(),
|
|
onedrive_token: z.string().optional(),
|
|
sharepoint_site_url: z.string().optional(),
|
|
sharepoint_token: z.string().optional(),
|
|
auth_mode: z.enum(["local", "free"]).optional(),
|
|
import_time: z.string().optional(),
|
|
fetch_mode: z.enum(["daily", "weekly", "monthly", "interval", "scheduled"]).optional(),
|
|
fetch_interval_minutes: z.string().optional(),
|
|
fetch_week_day: z.string().optional(),
|
|
fetch_month_day: z.string().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const toSave: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(input)) {
|
|
if (v !== undefined && v !== "••••••••") toSave[k] = v;
|
|
}
|
|
await setSettings(toSave);
|
|
// Recharger le cron si la planification a changé
|
|
if (toSave.import_time || toSave.fetch_mode || toSave.fetch_interval_minutes || toSave.fetch_week_day || toSave.fetch_month_day) {
|
|
await scheduleDailyImport();
|
|
}
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ─── Utilisateurs locaux ─────────────────────────────────────────────────────
|
|
users: router({
|
|
list: adminProcedure.query(async () => {
|
|
return getLocalUsers();
|
|
}),
|
|
|
|
create: adminProcedure
|
|
.input(
|
|
z.object({
|
|
name: z.string().min(2).max(255),
|
|
username: z.string().min(2).max(128).optional(),
|
|
email: z.string().email().optional(),
|
|
password: z.string().min(8),
|
|
role: z.enum(["admin", "user", "readonly"]).default("user"),
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const passwordHash = await hashPassword(input.password);
|
|
await createLocalUser({
|
|
name: input.name,
|
|
username: input.username ?? null,
|
|
email: input.email ? input.email.toLowerCase() : null,
|
|
passwordHash,
|
|
role: input.role,
|
|
isActive: true,
|
|
});
|
|
return { success: true };
|
|
}),
|
|
|
|
update: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.number().int().positive(),
|
|
name: z.string().min(2).max(255).optional(),
|
|
username: z.string().min(2).max(128).optional(),
|
|
email: z.string().email().optional(),
|
|
password: z.string().min(8).optional(),
|
|
role: z.enum(["admin", "user", "readonly"]).optional(),
|
|
isActive: z.boolean().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const { id, password, ...rest } = input;
|
|
const data: Record<string, unknown> = { ...rest };
|
|
if (password) data.passwordHash = await hashPassword(password);
|
|
await updateLocalUser(id, data as Parameters<typeof updateLocalUser>[1]);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.number().int().positive() }))
|
|
.mutation(async ({ input }) => {
|
|
await deleteLocalUser(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
ensureAdmin: publicProcedure.mutation(async () => {
|
|
await ensureAdminExists();
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ─── Boîte à idées ───────────────────────────────────────────────────────────
|
|
ideas: router({
|
|
// Créer une nouvelle idée / question
|
|
create: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
titre: z.string().min(3).max(512),
|
|
message: z.string().min(10),
|
|
})
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
await createIdea({
|
|
userId: ctx.user.id,
|
|
userName: ctx.user.name ?? ctx.user.email ?? "Utilisateur",
|
|
titre: input.titre,
|
|
message: input.message,
|
|
});
|
|
return { success: true };
|
|
}),
|
|
|
|
// Lister toutes les idées (admin) ou les siennes (user)
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
if (ctx.user.role === "admin") {
|
|
return getAllIdeas();
|
|
}
|
|
return getIdeasByUser(ctx.user.id);
|
|
}),
|
|
|
|
// Répondre à une idée (admin uniquement)
|
|
repondre: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.number().int().positive(),
|
|
reponseAdmin: z.string().min(1),
|
|
statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]),
|
|
})
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
await repondreIdea(
|
|
input.id,
|
|
input.reponseAdmin,
|
|
ctx.user.name ?? ctx.user.email ?? "Admin",
|
|
input.statut
|
|
);
|
|
return { success: true };
|
|
}),
|
|
|
|
// Changer le statut (admin uniquement)
|
|
updateStatut: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.number().int().positive(),
|
|
statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]),
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
await updateIdeaStatut(input.id, input.statut);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
// ─── Flux RSS ───────────────────────────────────────────────────────────────────────────────────
|
|
rss: router({
|
|
// Lister tous les flux
|
|
list: protectedProcedure.query(async () => {
|
|
return getRssFeeds();
|
|
}),
|
|
|
|
// Créer un flux
|
|
create: adminProcedure
|
|
.input(z.object({
|
|
url: z.string().url("URL invalide"),
|
|
name: z.string().min(1, "Nom requis").max(255),
|
|
feedType: z.enum(["veille", "aap"]),
|
|
defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).optional(),
|
|
defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(),
|
|
autoRules: z.array(z.object({
|
|
keyword: z.string(),
|
|
value: z.string(),
|
|
})).optional(),
|
|
isActive: z.boolean().optional().default(true),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const id = await createRssFeed({
|
|
url: input.url,
|
|
name: input.name,
|
|
feedType: input.feedType,
|
|
defaultTypeVeille: input.defaultTypeVeille ?? null,
|
|
defaultCategorieAap: input.defaultCategorieAap ?? null,
|
|
autoRules: input.autoRules ?? null,
|
|
isActive: input.isActive ?? true,
|
|
});
|
|
return { id };
|
|
}),
|
|
|
|
// Modifier un flux
|
|
update: adminProcedure
|
|
.input(z.object({
|
|
id: z.number().int().positive(),
|
|
url: z.string().url("URL invalide").optional(),
|
|
name: z.string().min(1).max(255).optional(),
|
|
feedType: z.enum(["veille", "aap"]).optional(),
|
|
defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).nullable().optional(),
|
|
defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).nullable().optional(),
|
|
autoRules: z.array(z.object({
|
|
keyword: z.string(),
|
|
value: z.string(),
|
|
})).nullable().optional(),
|
|
isActive: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
await updateRssFeed(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
// Supprimer un flux
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.number().int().positive() }))
|
|
.mutation(async ({ input }) => {
|
|
await deleteRssFeed(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
// Activer / désactiver un flux
|
|
toggleActive: adminProcedure
|
|
.input(z.object({ id: z.number().int().positive(), isActive: z.boolean() }))
|
|
.mutation(async ({ input }) => {
|
|
await updateRssFeed(input.id, { isActive: input.isActive });
|
|
return { success: true };
|
|
}),
|
|
|
|
// Lire les paramètres globaux RSS
|
|
getSettings: protectedProcedure.query(async () => {
|
|
return getRssSettings();
|
|
}),
|
|
|
|
// Sauvegarder les paramètres globaux RSS
|
|
saveSettings: adminProcedure
|
|
.input(z.object({
|
|
fetchIntervalMinutes: z.number().int().min(5).max(10080).optional(),
|
|
scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, "Format HH:MM requis").optional(),
|
|
fetchMode: z.enum(["interval", "scheduled"]).optional(),
|
|
autoFetchEnabled: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await saveRssSettings(input);
|
|
// Recharger le planificateur (la lecture RSS est intégrée dans scheduleDailyImport)
|
|
await scheduleDailyImport();
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
});
|
|
export type AppRouter = typeof appRouter;
|