- Ajout de scheduleRssFetch() dans server/_core/index.ts - Planificateur démarré au lancement du serveur - Supporte les modes interval et scheduled depuis rss_settings - Rechargement dynamique lors de la sauvegarde des paramètres RSS - Supprime la dépendance à la tâche planifiée Manus externe
419 lines
16 KiB
TypeScript
419 lines
16 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 { scheduleRssFetch } from "./_core/index";
|
|
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
|
|
|
|
// ─── 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", "generale"]).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 };
|
|
}),
|
|
}),
|
|
// ─── 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 };
|
|
}),
|
|
}),
|
|
// ─── 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(),
|
|
})
|
|
)
|
|
.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);
|
|
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", "generale"]).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", "generale"]).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 RSS avec les nouveaux paramètres
|
|
await scheduleRssFetch();
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
});
|
|
export type AppRouter = typeof appRouter;
|